Files
2026-08-05 21:08:48 +08:00

1091 lines
42 KiB
Bash
Raw Permalink 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 版)
#
# 设计原则:
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
# 2. 只停 Gitea,MySQL 保持运行 —— 数据库容器全程不重建
# 3. 冷备份(Gitea 已停)+ 备份包完整性校验 —— 快照一致且可用
# 4. 升级前后比对表行数与迁移版本 —— 数据异常自动回滚
# 5. 回滚 = 恢复数据库 + 恢复配置 + 切回旧镜像
# Gitea 跨小版本升级会改数据库结构,旧版本无法直接读新库,
# 所以「只切镜像」的回滚是无效的,必须回灌 SQL 备份。
# 6. 全程不删仓库 —— 数据目录用合并解压覆盖,绝不 rm / mv
#
# 用法:
# bash upgrade.sh 升级到 GitHub 最新 release
# bash upgrade.sh --check 只检查,不做任何改动
# bash upgrade.sh --version 1.27.1
# bash upgrade.sh --rollback 回滚到上次升级前的状态
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ===== 输出样式 =====
if [ -t 1 ]; then
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
log() { echo -e "${GREEN}[✓]${NC} $*"; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
# ===== 全局状态 =====
GITHUB_REPO="go-gitea/gitea"
IMAGE_REPO="gitea/gitea"
DB_NAME="gitea"
SVC_APP="server" # docker-compose.yml 里的 Gitea 服务名
SVC_DB="db" # docker-compose.yml 里的 MySQL 服务名
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
MODE="upgrade" # upgrade | check | rollback
TARGET_VERSION=""
ASSUME_YES=0
FULL_BACKUP=0
RUN_DOCTOR=0
READY_TIMEOUT=300 # 大库迁移可能耗时数分钟
SAFETY_STAGE="none" # none → stopped → armed → done
DB_BACKUP=""
DATA_BACKUP=""
META_BACKUP=""
ROLLBACK_TAG=""
OLD_IMAGE_REF=""
CURRENT_VERSION=""
COUNTS_BEFORE=""
MIGRATION_BEFORE=""
# 用于比对的关键表。故意不含 action(活动流,行数极大且丢了不致命),
# COUNT(*) 在 InnoDB 上是索引全扫,表选择要克制。
FINGERPRINT_TABLES="user repository issue comment pull_request release
attachment lfs_meta_object package access_token public_key
gpg_key email_address org_user team webhook branch repo_unit"
# =============================================================
# 参数解析
# =============================================================
usage() {
cat <<'EOF'
Gitea 安全升级脚本
用法: bash upgrade.sh [选项]
选项:
-c, --check 只做预检和版本对比,不做任何改动
-v, --version <ver> 指定目标版本(如 1.27.1),默认取 GitHub 最新 release
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state
-y, --yes 跳过交互确认
-f, --full-backup 数据目录全量打包(含仓库/LFS/包,可能非常大)
默认只打包配置、密钥、索引等会被升级影响的部分,
仓库和 LFS 是内容寻址的追加式存储,升级不会改写
-t, --timeout <秒> 等待 Gitea 就绪的超时,默认 300(迁移大库需要时间)
--doctor 升级后运行 gitea doctor 一致性检查(仅提示,不触发回滚)
-h, --help 显示本帮助
示例:
bash upgrade.sh --check # 先看看会发生什么
bash upgrade.sh # 正式升级
bash upgrade.sh --version 1.26.4 # 升级到指定版本
bash upgrade.sh --full-backup # 连仓库一起备份(小实例推荐)
bash upgrade.sh --rollback # 升级后发现问题,回滚
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
-c|--check) MODE="check" ;;
-r|--rollback) MODE="rollback" ;;
-y|--yes) ASSUME_YES=1 ;;
-f|--full-backup) FULL_BACKUP=1 ;;
--doctor) RUN_DOCTOR=1 ;;
-v|--version)
shift
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
TARGET_VERSION="${1#v}"
;;
-t|--timeout)
shift
[ $# -gt 0 ] || { error "--timeout 需要一个参数"; exit 1; }
[[ "$1" =~ ^[0-9]+$ ]] || { error "--timeout 必须是整数秒"; exit 1; }
READY_TIMEOUT="$1"
;;
-h|--help) usage; exit 0 ;;
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
esac
shift
done
}
confirm() {
[ "$ASSUME_YES" -eq 1 ] && return 0
local prompt="$1" answer
echo ""
read -r -p "${prompt} [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# =============================================================
# 版本号比较
# =============================================================
# $1 < $2 返回 0
ver_lt() {
[ "$1" = "$2" ] && return 1
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}
# 取 major.minorGitea 只在 major.minor 相同时保证数据库结构一致
ver_series() { echo "$1" | cut -d. -f1,2; }
# =============================================================
# 环境加载与预检
# =============================================================
load_env() {
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
sed -i 's/\r$//' .env
set -a; source .env; set +a
DATA_DIR="${GITEA_DATA_DIR:-/var/lib/gitea}"
BACKUP_BASE="${BACKUP_DIR:-/var/backups/gitea}"
DOMAIN="${GITEA_DOMAIN:-}"
CURRENT_IMAGE="${GITEA_IMAGE:-gitea/gitea:1.25}"
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
error ".env 中缺少 DB_ROOT_PASSWORD,无法备份数据库"
exit 1
fi
}
# 统一的 MySQL 容器执行入口。用 MYSQL_PWD 传密码,避免密码出现在
# 进程列表里,也避开 mysql 客户端的 "password on the command line" 告警。
db_exec() {
docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SVC_DB" "$@"
}
db_query() {
db_exec mysql -u root -N -B -e "$1" 2>/dev/null | tr -d '\r'
}
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" ]
}
compose_container_id() {
docker compose ps -a -q "$SVC_APP" 2>/dev/null | head -1 || true
}
running_image_id() {
local cid
cid="$(compose_container_id)"
[ -n "$cid" ] || return 0
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
}
preflight() {
step "环境预检"
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
local missing=0
for cmd in docker curl tar gzip; do
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
done
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
[ "$missing" -eq 0 ] || exit 1
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
# MySQL 必须在跑:备份和行数比对都依赖它,且升级全程它都不会被重建
if ! db_running; then
error "MySQL 容器未运行,请先启动: docker compose up -d $SVC_DB"
exit 1
fi
if ! db_query "SELECT 1;" >/dev/null 2>&1; then
error "无法连接 MySQL,请检查 DB_ROOT_PASSWORD 是否与容器一致"
exit 1
fi
log "MySQL 连接正常"
# 确认 Gitea 已完成安装向导,否则没有可保护的数据
local ntables
ntables=$(db_query "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';" || true)
if ! [[ "$ntables" =~ ^[0-9]+$ ]] || [ "$ntables" -lt 10 ]; then
error "数据库 ${DB_NAME} 中只有 ${ntables:-0} 张表,Gitea 似乎尚未完成安装向导"
error "没有数据需要保护,直接改 .env 里的 GITEA_IMAGE 重新部署即可"
exit 1
fi
log "数据库 ${DB_NAME}${ntables} 张表"
collect_backup_paths
# 磁盘空间:SQL 备份按数据库物理体积的 1.5 倍估(压缩后通常远小于此),
# 数据备份按选中目录体积的 1.2 倍估,再留 500MB 余量
mkdir -p "$BACKUP_BASE"
local db_kb data_kb need_kb avail_kb
db_kb=$(db_query "SELECT IFNULL(CEIL(SUM(data_length+index_length)/1024),0) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';" || true)
[[ "$db_kb" =~ ^[0-9]+$ ]] || db_kb=0
data_kb=$(backup_paths_size_kb)
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
need_kb=$(( db_kb * 3 / 2 + data_kb * 6 / 5 + 512000 ))
if [ "$avail_kb" -lt "$need_kb" ]; then
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,预计需要 $((need_kb/1024))MB"
error "可清理旧备份: ls -lh $BACKUP_BASE"
exit 1
fi
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
info "数据库 $((db_kb/1024))MB,待备份数据 $((data_kb/1024))MB,可用空间 $((avail_kb/1024))MB"
}
# =============================================================
# 备份范围
# =============================================================
# Gitea 升级只改数据库结构和少量元数据;仓库对象、LFS、软件包是内容寻址的
# 追加式存储,迁移不会改写它们。默认因此跳过这些大块,否则一个上百 GB 的
# 实例根本备份不动 —— 但配置、各类密钥、索引必须备,丢了会话和 OAuth 全废。
collect_backup_paths() {
BACKUP_PATHS=()
local base rel
base="$(basename "$DATA_DIR")"
local always=(
"gitea/conf" # app.ini
"gitea/jwt" # OAuth2 签名密钥,丢了所有令牌失效
"gitea/https"
"ssh" # SSH host key,丢了所有客户端报中间人攻击
"gitea/indexers" # 代码/议题索引,与版本相关
"gitea/queues" # 持久化队列(LevelDB),跨版本格式可能变
"gitea/sessions"
"gitea/avatars"
"gitea/repo-avatars"
)
# 官方镜像的仓库根是 /data/git/repositories、LFS 在 /data/gitea/lfs
# 但 app.ini 里这些路径可以改。多列几个候选,反正下面按存在与否过滤,
# 漏备份比多备份危险得多。
local heavy=(
"git/repositories"
"gitea/repositories"
"git/lfs"
"gitea/lfs"
"gitea/packages"
"gitea/attachments"
"gitea/actions_log"
"gitea/actions_artifacts"
)
for rel in "${always[@]}"; do
[ -e "$DATA_DIR/$rel" ] && BACKUP_PATHS+=("$base/$rel")
done
if [ "$FULL_BACKUP" -eq 1 ]; then
for rel in "${heavy[@]}"; do
[ -e "$DATA_DIR/$rel" ] && BACKUP_PATHS+=("$base/$rel")
done
fi
[ "${#BACKUP_PATHS[@]}" -gt 0 ] || {
error "在 $DATA_DIR 下找不到任何 Gitea 数据子目录,路径是否正确?"
exit 1
}
}
backup_paths_size_kb() {
local parent total=0 p sz
parent="$(dirname "$DATA_DIR")"
for p in "${BACKUP_PATHS[@]}"; do
sz=$(du -sk "$parent/$p" 2>/dev/null | cut -f1 || echo 0)
[[ "$sz" =~ ^[0-9]+$ ]] || sz=0
total=$(( total + sz ))
done
echo "$total"
}
# =============================================================
# 版本解析
# =============================================================
resolve_target_version() {
if [ -n "$TARGET_VERSION" ]; then
info "使用指定版本: $TARGET_VERSION"
return 0
fi
info "查询 GitHub 最新 release..."
TARGET_VERSION=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
if [ -z "$TARGET_VERSION" ]; then
error "无法从 GitHub 获取最新版本(网络问题?)"
error "请手动指定: bash upgrade.sh --version 1.27.1"
exit 1
fi
log "GitHub 最新 release: $TARGET_VERSION"
}
# 当前运行版本:优先问容器里的二进制(不受 REQUIRE_SIGNIN 影响),
# 拿不到再退回 HTTP 接口
detect_current_version() {
local cid v=""
cid="$(compose_container_id)"
if [ -n "$cid" ] && [ "$(docker inspect "$cid" --format '{{.State.Running}}' 2>/dev/null)" = "true" ]; then
v=$(docker exec "$cid" gitea --version 2>/dev/null | head -1 | awk '{print $3}' | cut -d+ -f1 || true)
fi
if [ -z "$v" ]; then
v=$(curl -sf --max-time 10 "http://127.0.0.1:3000/api/v1/version" 2>/dev/null \
| grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"\([^"]*\)"$/\1/' | cut -d+ -f1 || true)
fi
echo "$v"
}
# Gitea 数据库迁移版本号,是判断「结构是否被改过」最直接的指标
get_migration_version() {
db_query "SELECT version FROM \`${DB_NAME}\`.version WHERE id=1;" | head -1
}
check_version_transition() {
local cur="$1" tgt="$2"
if [ -z "$cur" ]; then
warn "无法探测当前 Gitea 版本,跳过版本路径检查"
return 0
fi
if [ "$cur" = "$tgt" ]; then
return 0
fi
if ver_lt "$tgt" "$cur"; then
# 降级:同 major.minor 的补丁降级结构不变,尚可接受;跨小版本降级
# 会让新版数据库撞上旧版二进制,Gitea 会直接拒绝启动
if [ "$(ver_series "$cur")" = "$(ver_series "$tgt")" ]; then
warn "这是补丁级降级 ${cur}${tgt},数据库结构不变,风险可控"
else
error "拒绝执行跨小版本降级: ${cur}${tgt}"
error "Gitea 的数据库迁移是单向的,旧版二进制遇到新版数据库会直接退出:"
error " \"newer database for this old Gitea release ... Gitea will exit\""
error "要退回旧版本,请用升级前的备份回滚:"
error " bash upgrade.sh --rollback"
exit 1
fi
return 0
fi
if [ "$(ver_series "$cur")" != "$(ver_series "$tgt")" ]; then
echo ""
warn "跨小版本升级 ${cur}${tgt}:Gitea 会在启动时改写数据库结构"
warn "这个过程不可逆 —— 之后只能靠本脚本的 SQL 备份回滚,不能只切镜像"
warn "建议先读一遍变更日志(尤其是自定义模板相关的破坏性变更):"
warn " https://github.com/${GITHUB_REPO}/blob/main/CHANGELOG.md"
else
info "补丁级升级 ${cur}${tgt},数据库结构不变"
fi
}
# =============================================================
# 数据指纹
# =============================================================
snapshot_counts() {
local existing t sql="" out=""
existing=$(db_query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';")
[ -n "$existing" ] || return 1
for t in $FINGERPRINT_TABLES; do
grep -qx "$t" <<<"$existing" || continue
[ -n "$sql" ] && sql+=" UNION ALL "
sql+="SELECT '$t', COUNT(*) FROM \`${DB_NAME}\`.\`$t\`"
done
[ -n "$sql" ] || return 1
out=$(db_query "$sql;" | awk 'NF==2{printf "%s=%s ", $1, $2}')
[ -n "$out" ] || return 1
echo "${out% }"
}
print_counts() {
local label="$1" counts="$2" kv
echo " ${label}:"
for kv in $counts; do
printf " %-18s %s\n" "${kv%%=*}" "${kv##*=}"
done
}
# =============================================================
# 备份
# =============================================================
cold_backup() {
step "冷备份(Gitea 已停止,MySQL 仍在运行)"
mkdir -p "$BACKUP_BASE"
chmod 700 "$BACKUP_BASE"
COUNTS_BEFORE="$(snapshot_counts)" || { error "无法统计数据表行数"; exit 1; }
MIGRATION_BEFORE="$(get_migration_version || true)"
print_counts "升级前数据统计" "$COUNTS_BEFORE"
info "数据库迁移版本: ${MIGRATION_BEFORE:-未知}"
# ---- 1. 数据库 ----
DB_BACKUP="${BACKUP_BASE}/pre-upgrade-db-${TIMESTAMP}.sql.gz"
info "导出数据库 → $DB_BACKUP"
local dump_opts=(-u root --single-transaction --routines --triggers
--hex-blob --default-character-set=utf8mb4 --databases "$DB_NAME")
# 无 GTID 的实例上 mysqldump 会写入 SET @@GLOBAL.GTID_PURGED,回灌时报错;
# MariaDB 没有这个选项,所以先探测再决定加不加。
# 先把 --help 收进变量再匹配,不能写成 `... | grep -q`grep -q 命中即退出会让
# 上游收到 SIGPIPE,在 pipefail 下整条管道被判为失败,探测结果就反了。
local dump_help
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
if ! db_exec mysqldump "${dump_opts[@]}" | gzip > "$DB_BACKUP"; then
error "数据库导出失败,服务未做不可逆改动"
exit 1
fi
verify_db_backup "$DB_BACKUP"
log "数据库备份完成: $(du -h "$DB_BACKUP" | cut -f1)"
# ---- 2. 数据目录(按 collect_backup_paths 选定的范围)----
DATA_BACKUP="${BACKUP_BASE}/pre-upgrade-data-${TIMESTAMP}.tar.gz"
info "打包数据目录 → $DATA_BACKUP"
if [ "$FULL_BACKUP" -eq 1 ]; then
info " 模式: 全量(含仓库/LFS/包),可能耗时较久"
else
info " 模式: 增量(配置/密钥/索引/头像),仓库与 LFS 未打包"
fi
printf ' %s\n' "${BACKUP_PATHS[@]}"
tar czf "$DATA_BACKUP" -C "$(dirname "$DATA_DIR")" "${BACKUP_PATHS[@]}"
verify_data_backup "$DATA_BACKUP"
log "数据备份完成: $(du -h "$DATA_BACKUP" | cut -f1)"
# ---- 3. 部署文件 ----
META_BACKUP="${BACKUP_BASE}/pre-upgrade-meta-${TIMESTAMP}.tar.gz"
local meta_items=(.env docker-compose.yml)
[ -d nginx ] && meta_items+=(nginx)
tar czf "$META_BACKUP" -C "$SCRIPT_DIR" "${meta_items[@]}"
chmod 600 "$META_BACKUP" "$DB_BACKUP"
log "部署文件备份完成: $META_BACKUP"
}
verify_db_backup() {
local f="$1"
info "校验 SQL 备份..."
gzip -t "$f" 2>/dev/null || { error "SQL 备份 gzip 校验失败,文件已损坏"; exit 1; }
# 一次解压同时数建表语句和找结束标记。awk 会读完整个流,因此不会像
# `gunzip -c | grep -q` 那样让上游收到 SIGPIPE —— 在 pipefail 下那会把
# 好备份误判成坏的。"Dump completed" 只有 mysqldump 正常收尾才会写,
# 中途被 OOM / 断连截断的备份没有这行。
local summary tables completed
summary="$(gunzip -c "$f" | awk '
/^CREATE TABLE / { n++ }
/-- Dump completed/{ done = 1 }
END { print n+0, done+0 }
')" || { error "SQL 备份无法完整解压"; exit 1; }
tables="${summary%% *}"
completed="${summary##* }"
if [ "$completed" != "1" ]; then
error "SQL 备份没有结束标记,导出被截断了"
exit 1
fi
if [ "$tables" -lt 10 ]; then
error "SQL 备份中只有 ${tables} 条 CREATE TABLE,内容不完整"
exit 1
fi
log "SQL 备份校验通过(${tables} 张表,含结束标记)"
}
verify_data_backup() {
local f="$1" listing
info "校验数据备份包..."
listing="$(tar tzf "$f")" || { error "数据备份包损坏,无法列出内容"; exit 1; }
grep -q 'gitea/conf/app\.ini$' <<<"$listing" || warn "备份包中没有 app.ini"
grep -q '/ssh/' <<<"$listing" || warn "备份包中没有 SSH host key 目录"
log "数据备份包可正常读取"
}
# =============================================================
# 镜像与容器
# =============================================================
tag_rollback_image() {
step "锚定当前镜像(用于回滚)"
local image_id
image_id="$(running_image_id)"
if [ -z "$image_id" ]; then
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
fi
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
ROLLBACK_TAG="${IMAGE_REPO}:pre-upgrade-${TIMESTAMP}"
docker tag "$image_id" "$ROLLBACK_TAG"
OLD_IMAGE_REF="$CURRENT_IMAGE"
log "当前镜像已标记为 $ROLLBACK_TAG"
info "镜像 ID: ${image_id#sha256:}"
}
pull_target_image() {
step "拉取目标镜像(此步骤不影响运行中的服务)"
local target="${IMAGE_REPO}:${TARGET_VERSION}"
info "docker pull $target ..."
if ! docker pull "$target"; then
error "镜像拉取失败,服务未做任何改动,可安全重试"
error "国内服务器可在 .env 的 DOCKER_REGISTRY_MIRRORS 里换个加速源"
exit 1
fi
log "镜像已就绪: $target"
}
switch_image_in_env() {
local new_ref="$1"
[ -f ".env.bak-${TIMESTAMP}" ] || { cp .env ".env.bak-${TIMESTAMP}"; chmod 600 ".env.bak-${TIMESTAMP}"; }
if grep -q '^GITEA_IMAGE=' .env; then
sed -i "s|^GITEA_IMAGE=.*|GITEA_IMAGE=${new_ref}|" .env
else
printf '\nGITEA_IMAGE=%s\n' "$new_ref" >> .env
fi
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已把旧值导出到
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
export GITEA_IMAGE="$new_ref"
info ".env 中 GITEA_IMAGE → $new_ref"
}
# --no-deps 是关键:不加的话 --force-recreate 会连 MySQL 容器一起重建,
# 平白多一次数据库停机,也多一份风险。db 全程保持运行。
recreate_app() {
docker compose up -d --force-recreate --no-deps "$SVC_APP"
}
# curl 的 -w '%{http_code}' 在连不上时「既向 stdout 输出 000,又返回非零退出码」。
# 因此绝不能写成 `code=$(curl ... || echo "000")` —— 那会让两个 000 拼成 "000000"
# 于是 `[ "$code" != "000" ]` 恒成立,wait_healthy 会在服务根本没起来时就误判就绪,
# 而 verify_endpoints 又认不出这个状态码,最终把一次正常的迁移误杀成回滚。
http_code() {
local url="$1" timeout="${2:-8}" code
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$timeout" "$url" 2>/dev/null)" || true
[[ "$code" =~ ^[0-9]{3}$ ]] || code="000"
echo "$code"
}
# Gitea 在跑完数据库迁移之后才会监听 HTTP 端口,所以「端口有响应」
# 就等价于「迁移已完成」。返回任意 HTTP 状态码都算就绪 —— 开了
# REQUIRE_SIGNIN 时 /api/v1/version 会返回 401/403,那也是活着。
wait_healthy() {
local max_wait="${1:-$READY_TIMEOUT}" i cid code state restarts base_restarts
cid="$(compose_container_id)"
base_restarts=$(docker inspect "$cid" --format '{{.RestartCount}}' 2>/dev/null || echo 0)
info "等待 Gitea 就绪(最多 ${max_wait}s,跨版本迁移会比较慢)..."
for i in $(seq 1 "$max_wait"); do
code="$(http_code "http://127.0.0.1:3000/api/v1/version" 3)"
if [ "$code" != "000" ]; then
log "Gitea 已就绪(${i}sHTTP ${code}"
return 0
fi
# 迁移失败会让容器退出,restart:always 把它拉起来反复失败。
# 与其干等到超时,不如发现重启就立刻收手。
state=$(docker inspect "$cid" --format '{{.State.Status}}' 2>/dev/null || echo "unknown")
restarts=$(docker inspect "$cid" --format '{{.RestartCount}}' 2>/dev/null || echo 0)
if [ "$restarts" -gt "$base_restarts" ]; then
error "容器已重启 $((restarts - base_restarts)) 次,启动失败(多半是迁移报错)"
return 1
fi
if [ "$state" = "exited" ] || [ "$state" = "dead" ]; then
error "容器状态为 $state,启动失败"
return 1
fi
[ $(( i % 30 )) -eq 0 ] && info " 已等待 ${i}s,仍在启动中..."
sleep 1
done
error "等待超时(${max_wait}s"
return 1
}
# =============================================================
# 验证
# =============================================================
verify_running_image() {
local expect="$1" cid actual_ref actual_id expect_id
cid="$(compose_container_id)"
[ -n "$cid" ] || { error "找不到容器"; return 1; }
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
if [ "$actual_id" != "$expect_id" ]; then
error "容器未运行在目标镜像上"
error " 期望: $expect ($expect_id)"
error " 实际: $actual_ref ($actual_id)"
return 1
fi
log "容器镜像确认: $actual_ref"
return 0
}
verify_version() {
local expect="$1" actual
actual="$(detect_current_version)"
if [ -z "$actual" ]; then
warn "无法读取运行版本,跳过版本校验(镜像 ID 校验已通过)"
return 0
fi
if [ "$actual" != "$expect" ]; then
error "运行版本与目标不符: 期望 $expect,实际 $actual"
return 1
fi
log "运行版本确认: $actual"
return 0
}
verify_endpoints() {
step "接口验证"
local failed=0 code
code="$(http_code "http://127.0.0.1:3000/api/v1/version" 10)"
case "$code" in
200) log "/api/v1/version → 200" ;;
401|403) log "/api/v1/version → ${code}(已开启强制登录,属预期)" ;;
000) error "/api/v1/version 无响应"; failed=1 ;;
*) error "/api/v1/version → ${code}"; failed=1 ;;
esac
# 首页:模板损坏(自定义模板与新版不兼容)会在这里暴露成 5xx
code="$(http_code "http://127.0.0.1:3000/" 10)"
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
error "首页 → ${code}(模板渲染失败?)"; failed=1
else
log "首页 → ${code}"
fi
# 内置 SSH 服务:仓库 clone 依赖它。
# 注意不能只测「TCP 能否连上」—— docker-proxy 会抢先接受连接,哪怕容器里
# 根本没有服务在监听,那样测出来永远是通的。必须实际读到 SSH 版本 banner。
# 而且只能按行读(banner 以 CRLF 结尾),不能用 `head -c N`banner 只有
# 十来个字节,服务端在客户端应答前不会再发数据,head -c 会一直阻塞到超时
# 被杀、什么都读不出来,把一个健康的服务误报成故障。
local ssh_port="${SSH_PORT:-2222}" banner
banner="$(timeout 6 bash -c "exec 3<>/dev/tcp/127.0.0.1/${ssh_port}; head -n 1 <&3" 2>/dev/null | tr -d '\r\n' || true)"
if [[ "$banner" == SSH-* ]]; then
log "SSH 端口 ${ssh_port} 正常(banner: ${banner}"
else
warn "SSH 端口 ${ssh_port} 未返回 SSH bannerGit clone 可能不可用"
fi
# 经 Nginx 的外部访问:失败只告警,属代理层问题,不触发回滚
if [ -n "$DOMAIN" ]; then
code="$(http_code "https://${DOMAIN}/api/v1/version" 15)"
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then
log "https://${DOMAIN}/api/v1/version → ${code}Nginx 反代正常)"
else
warn "https://${DOMAIN}/api/v1/version → ${code}(检查 Nginx/证书,不影响升级判定)"
fi
fi
return $failed
}
verify_data_integrity() {
step "数据完整性验证"
local counts_after migration_after
counts_after="$(snapshot_counts)" || { error "升级后无法统计数据表行数"; return 1; }
migration_after="$(get_migration_version || true)"
print_counts "升级后数据统计" "$counts_after"
info "数据库迁移版本: ${MIGRATION_BEFORE:-未知}${migration_after:-未知}"
# 迁移版本只能前进。倒退意味着连错了库或数据被回灌成了旧状态
if [[ "$MIGRATION_BEFORE" =~ ^[0-9]+$ ]] && [[ "$migration_after" =~ ^[0-9]+$ ]]; then
if [ "$migration_after" -lt "$MIGRATION_BEFORE" ]; then
error "数据库迁移版本倒退: $MIGRATION_BEFORE$migration_after"
return 1
fi
fi
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
local kv table before after lost=0
for kv in $COUNTS_BEFORE; do
table="${kv%%=*}"; before="${kv##*=}"
after=$(tr ' ' '\n' <<<"$counts_after" | grep "^${table}=" | cut -d= -f2 || true)
# 升级后表被重命名/合并(Gitea 迁移偶有此事)→ 跳过,不误判为丢数据
if [ -z "$after" ]; then
warn "表 $table 在新版中不存在(可能被迁移重命名),跳过比对"
continue
fi
if [ "$after" -lt "$before" ]; then
error "表 $table 行数减少: $before$after"
lost=1
fi
done
if [ "$lost" -eq 1 ]; then
error "检测到数据丢失!"
return 1
fi
log "所有表行数无减少,数据完好"
return 0
}
# Gitea 自带的一致性检查。只作提示,任何结果都不触发回滚 ——
# doctor 经常报一些历史遗留的良性问题,拿它当回滚依据会误伤。
run_doctor() {
step "gitea doctor 一致性检查(仅提示)"
local cid
cid="$(compose_container_id)"
[ -n "$cid" ] || { warn "找不到容器,跳过"; return 0; }
if docker exec -u git "$cid" gitea doctor check \
--config /data/gitea/conf/app.ini 2>&1 | tail -40; then
log "doctor 检查结束"
else
warn "doctor 报告了问题(见上方输出),不影响本次升级判定"
warn "如需修复: docker compose exec -u git $SVC_APP gitea doctor check --fix"
fi
return 0
}
# =============================================================
# 回滚
# =============================================================
save_state() {
cat > "$STATE_FILE" <<EOF
# Gitea 升级状态记录 —— 由 upgrade.sh 自动生成
UPGRADE_TIMESTAMP=${TIMESTAMP}
FROM_VERSION=${CURRENT_VERSION}
TO_VERSION=${TARGET_VERSION}
DB_BACKUP=${DB_BACKUP}
DATA_BACKUP=${DATA_BACKUP}
META_BACKUP=${META_BACKUP}
FULL_BACKUP=${FULL_BACKUP}
ROLLBACK_TAG=${ROLLBACK_TAG}
OLD_IMAGE_REF=${OLD_IMAGE_REF}
NEW_IMAGE_REF=${IMAGE_REPO}:${TARGET_VERSION}
DATA_DIR=${DATA_DIR}
MIGRATION_BEFORE=${MIGRATION_BEFORE}
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
EOF
chmod 600 "$STATE_FILE"
}
# 回灌 SQL 备份。这是 Gitea 回滚的核心 —— 光切镜像没用,
# 旧版二进制看到新版结构的库会拒绝启动。
restore_database() {
local dump="$1"
[ -f "$dump" ] || { error "SQL 备份不存在: $dump"; return 1; }
info "回灌数据库备份 $dump ..."
gzip -t "$dump" 2>/dev/null || { error "SQL 备份已损坏,无法恢复"; return 1; }
# 备份用的是 --databases(不带 --add-drop-database),每张表自带
# DROP TABLE IF EXISTS,逐表原样替换,不会动 MySQL 里的用户与授权。
# 新版新增的孤儿表会留下,旧版 Gitea 会忽略它们,无害。
if ! gunzip -c "$dump" | db_exec mysql -u root; then
error "数据库回灌失败"
return 1
fi
local n mv
n=$(db_query "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`user\`;" 2>/dev/null | head -1 || true)
[[ "$n" =~ ^[0-9]+$ ]] || { error "恢复后无法读取 user 表"; return 1; }
mv="$(get_migration_version || true)"
log "数据库已恢复(user 表 ${n} 行,迁移版本 ${mv:-未知}"
return 0
}
# 合并式解压:只覆盖备份包里有的路径。
# 绝不对数据目录做 rm / mv —— 那底下可能是几百 GB 的仓库。
restore_data_dir() {
local tarball="$1"
[ -f "$tarball" ] || { error "数据备份不存在: $tarball"; return 1; }
local parent
parent="$(dirname "$DATA_DIR")"
info "恢复数据目录(合并覆盖,仓库不受影响)..."
tar xzf "$tarball" -C "$parent" || { error "数据目录恢复失败"; return 1; }
# root 解压时 GNU tar 默认已还原归档中的属主,这里只是再补一道保险。
# 只 chown 归档里真正列出的路径 —— 绝不对 $DATA_DIR 整体递归 chown
# 那底下可能挂着上百 GB 的 LFS 与仓库,递归一遍要跑很久且毫无必要。
tar tzf "$tarball" | sed "s|^|${parent}/|" | tr '\n' '\0' \
| xargs -0 --no-run-if-empty chown -h 1000:1000 2>/dev/null || true
log "数据目录已从备份恢复"
return 0
}
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 与数据都未改动
emergency_start_old() {
step "紧急恢复"
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
docker compose up -d "$SVC_APP" || true
if wait_healthy 120; then
log "原服务已恢复运行,本次升级未造成任何影响"
else
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
error "查看日志: docker compose logs --tail 100 $SVC_APP"
fi
}
on_exit() {
local code=$?
trap - EXIT ERR
[ "$code" -eq 0 ] && return 0
case "$SAFETY_STAGE" in
stopped) emergency_start_old ;;
armed) do_rollback "脚本异常中止(退出码 $code" ;;
*) : ;;
esac
return 0
}
do_rollback() {
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
SAFETY_STAGE="done"
set +e
local reason="$1"
step "自动回滚"
error "触发原因: $reason"
docker compose stop "$SVC_APP" || true
restore_database "$DB_BACKUP" || {
error "数据库自动恢复失败!请手动处理:"
error " gunzip -c $DB_BACKUP | docker compose exec -T $SVC_DB mysql -u root -p"
error "备份文件保留在: $DB_BACKUP"
exit 1
}
restore_data_dir "$DATA_BACKUP" || {
error "数据目录恢复失败,请手动处理:"
error " tar xzf $DATA_BACKUP -C $(dirname "$DATA_DIR")"
exit 1
}
switch_image_in_env "$ROLLBACK_TAG"
recreate_app
if wait_healthy 180; then
log "回滚完成,已恢复到升级前状态(${CURRENT_VERSION:-旧版本}"
else
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100 $SVC_APP"
fi
echo ""
warn "升级失败已回滚。备份保留在:"
warn " 数据库: $DB_BACKUP"
warn " 数据: $DATA_BACKUP"
exit 1
}
manual_rollback() {
step "手动回滚"
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
# shellcheck disable=SC1090
source "$STATE_FILE"
echo ""
info "将回滚到:"
info " 版本: ${FROM_VERSION:-未知} (当前 ${TO_VERSION:-未知})"
info " 镜像: $ROLLBACK_TAG"
info " 数据库: $DB_BACKUP"
info " 数据: $DATA_BACKUP"
info " 时间: $UPGRADE_TIMESTAMP"
echo ""
[ -f "$DB_BACKUP" ] || { error "数据库备份不存在: $DB_BACKUP"; exit 1; }
[ -f "$DATA_BACKUP" ] || { error "数据备份不存在: $DATA_BACKUP"; exit 1; }
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
if ! db_running; then
error "MySQL 容器未运行,无法回灌数据库: docker compose up -d $SVC_DB"
exit 1
fi
warn "回滚会把数据库整体退回到 ${UPGRADE_TIMESTAMP} 的状态"
warn "升级之后产生的所有变更(新提交的 issue、PR、评论、新建仓库记录)都会丢失"
if [ "${FULL_BACKUP:-0}" != "1" ]; then
warn "本次备份未包含仓库与 LFS 文件,它们不会被改动 —— 但数据库回退后,"
warn "升级期间新建仓库的目录会成为数据库里查不到的孤儿目录(不影响使用)"
fi
confirm "确认回滚?" || { info "已取消"; exit 0; }
docker compose stop "$SVC_APP" || true
restore_database "$DB_BACKUP" || exit 1
restore_data_dir "$DATA_BACKUP" || exit 1
switch_image_in_env "$ROLLBACK_TAG"
recreate_app
if wait_healthy 180; then
log "回滚完成"
verify_endpoints || warn "接口验证有告警,请检查日志"
else
error "服务未就绪,请检查: docker compose logs --tail 100 $SVC_APP"
exit 1
fi
}
# =============================================================
# 主流程
# =============================================================
show_plan() {
echo ""
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
echo " 域名: ${DOMAIN:-未配置}"
echo " 当前镜像: $CURRENT_IMAGE"
echo " 当前版本: ${CURRENT_VERSION:-未知(服务未运行?)}"
echo " 目标版本: ${IMAGE_REPO}:${TARGET_VERSION}"
echo " 数据目录: $DATA_DIR"
echo " 备份目录: $BACKUP_BASE"
echo " 备份范围: $([ "$FULL_BACKUP" -eq 1 ] && echo '全量(含仓库/LFS' || echo '配置+密钥+索引(不含仓库/LFS')"
echo ""
echo " 执行步骤:"
echo " 1. 拉取新镜像(失败则零影响退出)"
echo " 2. 锚定当前镜像为回滚点"
echo " 3. 停止 Gitea 容器(MySQL 保持运行)"
echo " 4. mysqldump + 打包数据目录 + 校验备份完整性"
echo " 5. 切换镜像并启动,Gitea 自动执行数据库迁移"
echo " 6. 验证镜像/版本/接口 + 比对表行数与迁移版本"
echo " 7. 任一验证失败 → 自动回滚(回灌 SQL + 切回旧镜像)"
echo ""
echo " 注意: MySQL 容器(mysql:8.4)不在本次升级范围内"
echo -e "${BOLD}═════════════════════════════${NC}"
}
main() {
parse_args "$@"
echo -e "${CYAN}${BOLD}"
echo " ____ _ _"
echo " / ___|(_) |_ ___ __ _"
echo "| | _ | | __/ _ \\/ _\` |"
echo "| |_| || | || __/ (_| |"
echo " \\____|_|\\__\\___|\\__,_| Upgrade Script"
echo -e "${NC}"
load_env
if [ "$MODE" = "rollback" ]; then
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
manual_rollback
exit 0
fi
preflight
CURRENT_VERSION="$(detect_current_version)"
resolve_target_version
check_version_transition "$CURRENT_VERSION" "$TARGET_VERSION"
show_plan
# 已是目标版本?
local cur_image_id target_image_id
cur_image_id="$(running_image_id)"
target_image_id=$(docker image inspect "${IMAGE_REPO}:${TARGET_VERSION}" \
--format '{{.Id}}' 2>/dev/null || true)
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
echo ""
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
[ "$MODE" = "check" ] && exit 0
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
fi
if [ "$MODE" = "check" ]; then
echo ""
info "--check 模式,未做任何改动"
exit 0
fi
confirm "确认开始升级?" || { info "已取消"; exit 0; }
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
pull_target_image
tag_rollback_image
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
trap on_exit EXIT
trap 'exit 1' ERR
step "停止 Gitea 容器"
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
docker compose stop "$SVC_APP"
log "Gitea 已停止(MySQL 仍在运行,用于备份)"
cold_backup
save_state
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
step "启动新版本(Gitea 将自动执行数据库迁移)"
switch_image_in_env "${IMAGE_REPO}:${TARGET_VERSION}"
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
recreate_app
if ! wait_healthy "$READY_TIMEOUT"; then
docker compose logs --tail 60 "$SVC_APP" || true
do_rollback "Gitea 在 ${READY_TIMEOUT} 秒内未就绪"
fi
if ! verify_running_image "${IMAGE_REPO}:${TARGET_VERSION}"; then
do_rollback "容器未运行在目标镜像上"
fi
if ! verify_version "$TARGET_VERSION"; then
do_rollback "运行版本与目标版本不符"
fi
if ! verify_endpoints; then
do_rollback "接口验证失败"
fi
if ! verify_data_integrity; then
do_rollback "数据完整性验证失败"
fi
# ===== 成功 =====
SAFETY_STAGE="done"
trap - EXIT ERR
[ "$RUN_DOCTOR" -eq 1 ] && run_doctor
step "升级成功"
echo ""
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
echo " 版本: ${CURRENT_VERSION:-未知}${TARGET_VERSION}"
echo " 镜像: ${IMAGE_REPO}:${TARGET_VERSION}"
echo " 数据库备份: $DB_BACKUP"
echo " 数据备份: $DATA_BACKUP"
echo " 回滚镜像: $ROLLBACK_TAG"
echo ""
echo " 如需回滚: bash upgrade.sh --rollback"
echo " 查看日志: docker compose logs --tail 50 $SVC_APP"
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
echo ""
warn "请登录 Web 端确认仓库、议题、LFS 都正常后,再考虑清理回滚镜像:"
warn " docker image rm $ROLLBACK_TAG"
warn "升级前的备份请至少保留到确认无误之后,不要急着删。"
}
main "$@"