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>
This commit is contained in:
2026-08-05 23:49:01 +08:00
co-authored by Claude Opus 5
parent e8cc0162af
commit 9be05edc9a
2 changed files with 312 additions and 41 deletions
+253 -35
View File
@@ -1,68 +1,286 @@
#!/usr/bin/env bash
set -euo pipefail
# -E 让 ERR trap 在函数内部同样生效(冷备份模式下必须靠它把服务拉回来)
set -Eeuo pipefail
# ============================================
# Gitea 备份脚本 (MySQL 版)
# 备份内容:MySQL 数据库 + Gitea 数据(含 LFS) + 配置
#
# 备份内容: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"
# 加载配置
if [ ! -f .env ]; then
echo "[ERROR] .env 文件不存在" >&2
exit 1
fi
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
sed -i 's/\r$//' .env
# shellcheck disable=SC1091
source .env
set +a
BACKUP_DIR="${BACKUP_DIR:-/var/backups/gitea}"
GITEA_DATA_DIR="${GITEA_DATA_DIR:-/var/lib/gitea}"
DATE=$(date +%Y%m%d_%H%M%S)
KEEP_DAYS=30
[ -n "${DB_ROOT_PASSWORD:-}" ] || die ".env 中缺少 DB_ROOT_PASSWORD"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# ===== 前置检查 =====
[ "$(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"
# 1. 备份 MySQL 数据库
# 防止 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 数据库..."
docker compose exec -T db mysqldump \
-u root -p"${DB_ROOT_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--databases gitea \
| gzip > "${BACKUP_DIR}/db_${DATE}.sql.gz"
log "数据库备份完成: db_${DATE}.sql.gz ($(du -h "${BACKUP_DIR}/db_${DATE}.sql.gz" | cut -f1))"
DB_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz"
# 2. 备份 Gitea 数据(仓库、LFS、附件、头像等)
log "正在备份 Gitea 数据目录(含 LFS)..."
tar czf "${BACKUP_DIR}/gitea_data_${DATE}.tar.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")"
log "数据目录备份完成: gitea_data_${DATE}.tar.gz ($(du -h "${BACKUP_DIR}/gitea_data_${DATE}.tar.gz" | cut -f1))"
# 包里有 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. 备份配置文件
# =============================================================
# 3. 部署配置
# =============================================================
log "正在备份配置文件..."
tar czf "${BACKUP_DIR}/config_${DATE}.tar.gz" \
-C "$SCRIPT_DIR" \
.env docker-compose.yml nginx/
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"
# 4. 清理过期备份
log "清理 ${KEEP_DAYS} 天前的备份..."
deleted=$(find "$BACKUP_DIR" -type f -mtime +${KEEP_DAYS} -print -delete | wc -l)
log "已清理 ${deleted} 个过期文件"
# 冷备份到此结束,尽早把服务放回去,后面的清理不该占用停机时间
restart_app_if_stopped
# 5. 输出备份摘要
# =============================================================
# 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 "备份目录: ${BACKUP_DIR}/"
log "备份集: ${DATE}(模式: ${MODE})"
ls -lh "${BACKUP_DIR}/"*"${DATE}"* 2>/dev/null || true
echo ""
log "总备份空间占用: $(du -sh "${BACKUP_DIR}" | cut -f1)"
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"