Files
zhangxiang 4ab2f3cd8e chore(infra): 新增 Gitea+Jenkins CI/CD 部署与凭据整改
- infra/cicd:docker-compose(gitea/jenkins)、JCasC(凭据统一 ${ENV} 注入,无硬编码)、
  备份/恢复、健康检查、凭据轮换与 git 历史清除脚本
- docs/deployment/cicd:安装、高可用备份监控、凭据事故复盘
- .env.example 仅为占位模板;真实 .env 由 .gitignore 排除
2026-09-20 10:37:57 +08:00

319 lines
14 KiB
Bash
Executable File
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
# ============================================================
# Novalon CI/CD 统一备份脚本
#
# 覆盖:
# 1. Gitea PostgreSQL 库(pg_dumpcustom format
# 2. Gitea 仓库裸库(/data/git/repositories
# 3. Jenkins 配置薄备份(排除 builds/ workspace/ 缓存,保留作业定义与凭据)
#
# 设计要点:
# - 每份产物落盘后立刻校验(大小非零 + 归档可列出),坏备份当场失败而不是等到恢复时才发现
# - 磁盘预检:剩余空间不足直接中止并告警(宿主 20G 已用 85%,写满会连带拖垮 nginx)
# - 异地同步可选:配了 RCLONE_REMOTE 才同步,否则明确告警"仅本地"
# - 全链路日志,失败走 ALERT_WEBHOOK_URL
#
# 用法:
# ./backup-cicd.sh # 标准备份(库 + 仓库 + Jenkins 配置)
# ./backup-cicd.sh --full # 追加 Gitea LFS/附件/头像
# ./backup-cicd.sh --dry-run # 只预检,不产出
# ./backup-cicd.sh --jenkins-only # 只备份 Jenkins
#
# crontab(每天 03:30,见 docs/deployment/cicd/04-ha-backup-monitoring.md:
# 30 3 * * * /home/novalon/docker-app/infra/cicd/backup/backup-cicd.sh >> /var/log/cicd-backup.log 2>&1
# ============================================================
set -Eeuo pipefail
# ---------- 配置 ----------
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
ENV_FILE="${ENV_FILE:-$DEPLOY_ROOT/infra/cicd/.env}"
BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
RETENTION="${BACKUP_RETENTION:-3}" # 兼容旧变量
FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}" # 全量保留份数(含 Gitea 仓库)
THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}" # 薄保留份数(仅 DB + Jenkins 配置)
MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
PG_CONTAINER="${PG_CONTAINER:-postgresql}"
JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}"
GITEA_DB_USER="${GITEA_DB_USER:-gitea}"
FULL=0
DRY_RUN=0
JENKINS_ONLY=0
SKIP_REPOS=0
# ---------- 参数 ----------
while [[ $# -gt 0 ]]; do
case "$1" in
--full) FULL=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--jenkins-only) JENKINS_ONLY=1; shift ;;
# 薄备份:跳过 Gitea 裸库(939MB,占单份 78%)。
# 依据:Git 仓库天然分布式,每份 clone 都是完整历史;
# 而 PostgreSQL 里的用户/权限/PR/Issue/Webhook 是唯一副本、不可重建。
# 故磁盘受限时优先保 DB,仓库交给「异地全量」或开发者本地副本。
--skip-repos) SKIP_REPOS=1; shift ;;
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
*) echo "未知参数: $1" >&2; exit 2 ;;
esac
done
# ---------- 加载 .env(若存在) ----------
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
RETENTION="${BACKUP_RETENTION:-3}"
FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}"
THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}"
MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
fi
TS="$(date +%Y%m%d_%H%M%S)"
STAMP_DIR="$BACKUP_ROOT/$TS"
LOG_PREFIX="[backup $TS]"
log() { echo "$LOG_PREFIX $*"; }
fail() { echo "$LOG_PREFIX$*" >&2; alert "备份失败: $*"; exit 1; }
alert() {
local msg="$1"
[[ -z "$ALERT_WEBHOOK_URL" ]] && return 0
curl -fsS -m 10 -X POST "$ALERT_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[CI/CD备份告警] $msg ($(hostname))\"}}" \
>/dev/null 2>&1 || true
}
# 任何未捕获错误都触发告警
trap 'fail "第 $LINENO 行发生未捕获错误"' ERR
require_container() {
local name="$1"
docker inspect "$name" >/dev/null 2>&1 || fail "容器不存在: $name"
local st; st="$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)"
[[ "$st" == "true" ]] || fail "容器未运行: $name"
}
# 归档完整性校验:非空 + tar/zip 可列出
verify_archive() {
local f="$1" kind="$2"
[[ -s "$f" ]] || fail "产物为空: $f"
local sz; sz=$(stat -c %s "$f")
(( sz > 1024 )) || fail "产物过小($sz B),判定失败: $f"
case "$kind" in
tar) tar -tzf "$f" >/dev/null 2>&1 || fail "tar 校验失败(归档损坏): $f" ;;
sql) gzip -t "$f" >/dev/null 2>&1 || fail "gzip 校验失败: $f" ;;
esac
log "✅ 校验通过 $(basename "$f") ($(numfmt --to=iec --suffix=B "$sz" 2>/dev/null || echo "${sz}B"))"
}
# ============================================================
log "========== CI/CD 备份开始 =========="
log "目标目录: $STAMP_DIR 保留份数: $RETENTION 全量: $FULL 跳过仓库: $SKIP_REPOS dry-run: $DRY_RUN"
# ---------- 预检 ----------
command -v docker >/dev/null || fail "docker 不可用"
FREE_GB=$(df -P "$BACKUP_ROOT" 2>/dev/null | awk 'NR==2{print int($4/1048576)}' \
|| df -P / | awk 'NR==2{print int($4/1048576)}')
log "备份卷可用空间: ${FREE_GB}GB(阈值 ${MIN_FREE_GB}GB"
if (( FREE_GB < MIN_FREE_GB )); then
fail "可用空间 ${FREE_GB}GB < 阈值 ${MIN_FREE_GB}GB,中止备份以免写满磁盘。请清理或扩容。"
fi
if (( JENKINS_ONLY == 0 )); then
require_container "$PG_CONTAINER"
require_container "$GITEA_CONTAINER"
fi
require_container "$JENKINS_CONTAINER"
if (( DRY_RUN == 1 )); then
log "dry-run:预检全部通过,未产出备份。"
exit 0
fi
mkdir -p "$STAMP_DIR"
# ============================================================
# 1. PostgreSQL —— Gitea 元数据(用户/权限/PR/Issue/Webhook
# ============================================================
if (( JENKINS_ONLY == 0 )); then
log "----- [1/3] PostgreSQL 导出 -----"
DB_DUMP="$STAMP_DIR/gitea-db-$TS.sql.gz"
if docker exec "$PG_CONTAINER" pg_dump -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" -Fc \
| gzip -9 > "$DB_DUMP"; then
verify_archive "$DB_DUMP" sql
else
rm -f "$DB_DUMP"; fail "pg_dump 执行失败"
fi
# ============================================================
# 2. Gitea 仓库裸库
# ============================================================
log "----- [2/3] Gitea 仓库导出 -----"
REPO_TAR="$STAMP_DIR/gitea-repos-$TS.tar.gz"
if (( SKIP_REPOS == 1 )); then
log "⏭ 已跳过仓库导出(--skip-repos):本次仅保 DB 与 Jenkins 配置"
log "ℹ️ 仓库是 Git 分布式的,但**不要长期依赖**此项跳过 —— 需有异地全量或开发者副本兜底"
elif [[ "$FULL" == "1" ]]; then
# 全量:仓库 + LFS + 附件 + 头像
docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
tar czf "/out/$(basename "$REPO_TAR")" -C /data git 2>/dev/null \
|| fail "Gitea 全量归档失败"
else
# 标准:仅裸库(LFS/附件可从对象存储或后续补,磁盘优先)
docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
sh -c 'tar czf /out/'"$(basename "$REPO_TAR")"' -C /data git/repositories' \
|| fail "Gitea 仓库归档失败"
verify_archive "$REPO_TAR" tar
fi
fi
# ============================================================
# 3. Jenkins 薄备份
# !关键:排除 builds/ workspace/ caches/ logs —— 这些占空间且可从作业定义重建
# !但必须包含 credentials.xml 与 secrets/(否则恢复后凭据全丢)
# ============================================================
log "----- [3/3] Jenkins 配置薄备份 -----"
JENKINS_TAR="$STAMP_DIR/jenkins-config-$TS.tar.gz"
# 归档整个 JENKINS_HOME 并排除可重建项,不再写死具体路径
# (曾踩坑:nodes/ 在部分版本不存在,tar 直接以非 0 退出导致整段备份失败)
# 体积参考(2026-09-14 实测):plugins 265M / war 111M / workspace 49M / updates 3.4M
# war、workspace、updates 均可从镜像或 SCM 重建,排除后单份约 80M
# ⚠️ ./plugins 也必须排除:实测它占薄备份 222MB 中的绝大部分,
# 而插件可由同目录导出的 jenkins-plugins-<ts>.txt 精确重装(jenkins-plugin-cli --plugin-file
# 排除后薄备份从 222MB 降到个位数 MB,磁盘受限场景下这是决定性差异
set +e
docker run --rm --volumes-from "$JENKINS_CONTAINER" -v "$STAMP_DIR:/out" alpine \
sh -c '
cd /var/jenkins_home
tar czf /out/'"$(basename "$JENKINS_TAR")"' \
--exclude="./jobs/*/builds" \
--exclude="./jobs/*/workspace" \
--exclude="./jobs/*/workspace@tmp" \
--exclude="./jobs/*/last*" \
--exclude="./jobs/*/nextBuildNumber" \
--exclude="./workspace" \
--exclude="./workspace@tmp" \
--exclude="./plugins" \
--exclude="./war" \
--exclude="./caches" \
--exclude="./updates" \
--exclude="./logs" \
--exclude="./.cache" \
--exclude="./tools" \
--exclude="./.java" \
. 2>/dev/null
'
TAR_RC=$?
set -e
# GNU/busybox tar0 成功;1 = "读取时文件发生变化"(Jenkins 在运行,属正常);>=2 才是致命错误
if (( TAR_RC >= 2 )); then
fail "Jenkins 归档失败(tar 退出码 $TAR_RC"
elif (( TAR_RC == 1 )); then
log "ℹ️ tar 退出码 1:归档过程中有文件变化(Jenkins 在运行),属可接受的警告"
fi
verify_archive "$JENKINS_TAR" tar
# 单独导出已安装插件清单,便于重建
docker exec "$JENKINS_CONTAINER" sh -c \
'ls /var/jenkins_home/plugins/*.jpi 2>/dev/null | xargs -n1 basename 2>/dev/null | sed "s/\.jpi$//"' \
> "$STAMP_DIR/jenkins-plugins-$TS.txt" 2>/dev/null || true
# ============================================================
# 校验:密钥文件必须存在,否则这份备份恢复不出来
# ============================================================
# Jenkins 2.5xx 起凭据解密依赖 secrets/master.key + secrets/hudson.util.Secret
# 旧版本(<2.4xx)才是根目录的 secret.key。三者取其二即可恢复,必须至少有 master.key。
# (曾踩坑:只按旧版路径 secret.key 判定,在 2.568.1 上误报"无法解密"
J_LIST="$(tar -tzf "$JENKINS_TAR" 2>/dev/null || true)"
# ⚠️ 必须用 herestring 而非管道:grep -q 命中即退出会关闭管道,
# 上游 printf 收到 SIGPIPE(141),在 set -o pipefail 下整条管道被判失败 -> 误报"密钥缺失"
if grep -qE '(^|/)(secrets/master\.key|secret\.key)$' <<< "$J_LIST"; then
if grep -qE '(^|/)(secrets/hudson\.util\.Secret|secret\.key\.not-so-secret)$' <<< "$J_LIST"; then
log "✅ 凭据解密所需的 master key 与 hudson.util.Secret 均已包含"
else
log "⚠️ 找到 master key 但缺少 hudson.util.Secret —— 恢复后部分凭据可能不可解密"
fi
else
fail "归档缺少 master keysecrets/master.key 或 secret.key)—— 恢复后所有 Jenkins 凭据将不可解密"
fi
# 生成清单
cat > "$STAMP_DIR/MANIFEST.txt" <<EOF
备份时间: $(date -Is)
主机: $(hostname)
Gitea: $(docker inspect -f '{{.Config.Image}}' "$GITEA_CONTAINER" 2>/dev/null || echo n/a)
Jenkins: $(docker exec "$JENKINS_CONTAINER" sh -c 'java -jar /usr/share/jenkins/jenkins.war --version' 2>/dev/null || echo n/a)
Postgres: $(docker inspect -f '{{.Config.Image}}' "$PG_CONTAINER" 2>/dev/null || echo n/a)
模式: $( if (( SKIP_REPOS == 1 )); then echo thin-db-only; elif [[ "$FULL" == "1" ]]; then echo full; else echo standard; fi )
注: Jenkins 归档已排除 plugins/(可重建)。恢复后执行:
jenkins-plugin-cli --plugin-file jenkins-plugins-<ts>.txt
或在 Manage Jenkins -> Plugins 按清单重装。
产物:
$(ls -lh "$STAMP_DIR" | awk 'NR>1{print " " $9 " " $5}')
EOF
# ============================================================
# 异地同步
# ============================================================
if [[ -n "$RCLONE_REMOTE" ]]; then
if command -v rclone >/dev/null 2>&1; then
log "同步到异地: $RCLONE_REMOTE:$RCLONE_PATH/$TS"
rclone copy "$STAMP_DIR" "$RCLONE_REMOTE:$RCLONE_PATH/$TS" --transfers 2 \
|| log "⚠️ 异地同步失败(本地备份仍然有效)"
else
log "⚠️ 已配置 RCLONE_REMOTE 但未安装 rclone,跳过异地同步"
fi
else
log "⚠️ 未配置 RCLONE_REMOTE —— 备份仅存本地。同盘备份不抗主机故障,强烈建议配置异地。"
fi
# ============================================================
# 保留策略 —— 全量与薄分开计数
# (踩坑:若统一按「最近 N 份」裁剪,周中的薄备份会把周日全量挤出窗口,
# 导致 Gitea 仓库只受保护 3 天,等于没有全量)
# FULL_RETENTION 份全量(含仓库) + THIN_RETENTION 份薄(不含仓库)
# ============================================================
log "清理过期备份(全量保留 ${FULL_RETENTION} 份,薄保留 ${THIN_RETENTION} 份)..."
declare -A KEEP=()
# 取「最近的 N 份全量 / 薄」目录
# ⚠️ 循环体内用 if 而非 `cond && echo`set -e 下条件为假会使子 shell 以非 0 退出,
# 导致进程替换提前终止(曾致第 302 行报"未捕获错误"
keep_mark() {
local want="$1" mode="$2" d found
for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
if [[ ! -d "$d" ]]; then continue; fi
if [[ -n "$(ls "$d"/gitea-repos-*.tar.gz 2>/dev/null | head -1)" ]]; then found=1; else found=0; fi
if [[ "$mode" == "full" && "$found" == "1" ]] || [[ "$mode" == "thin" && "$found" == "0" ]]; then
echo "$d"
fi
done | sort -r | head -n "$want"
}
for d in $(keep_mark "$FULL_RETENTION" full); do KEEP["$d"]=1; done
for d in $(keep_mark "$THIN_RETENTION" thin); do KEEP["$d"]=1; done
for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
if [[ ! -d "$d" ]]; then continue; fi
if [[ -z "${KEEP["$d"]:-}" ]]; then
rm -rf "$d" && log " 已清理: $(basename "$d")"
fi
done
TOTAL=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1)
log "========== 备份完成 =========="
log "目录: $STAMP_DIR 备份区总计: ${TOTAL:-unknown}"
log "剩余磁盘: $(df -h / | awk 'NR==2{print $5" 已用, "$4" 可用"}')"