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:
+253
-35
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user