内网异机备份 conf 驱动

占位符约定:生产机 A 192.168.1.10、备份机 B 192.168.1.20、数据库机 192.168.1.30、告警邮箱 you@example.com。请按自己环境替换。

背景

内网一台机器上跑着 Forgejo(用 Docker,rootless 镜像),数据库是另一台机器上的 PostgreSQL,同机还有 Nexus、certimate 等服务。目标很简单:把 Forgejo 备份到内网另一台 Linux 机器,最好顺手把框架做得能复用给其它服务。

听起来是一条 rsync 的事,真做起来有几个不踩不知道的坑。这篇把思路、实现和踩坑都记下来。

三个设计决定

1. 拉取模式(Pull),不是推送

让备份机 B 主动去拉生产机 A,而不是 A 推给 B。原因是安全姿态:B 持有一把只读 SSH key 去访问 A,A 不持有任何能写 B 的凭据。这样即使 A 被入侵或勒索,攻击者也动不了 B 上的历史备份。备份的第一要务是「攻破生产不等于攻破备份」。

2. 数据库必须 dump,不能只 rsync 文件

这是最容易错的一点。直接 rsync 正在运行的数据库数据文件得到的是崩溃一致的副本,恢复时大概率坏掉。正确做法是用 pg_dump(MySQL 则 mysqldump)导出逻辑快照。而且——

Git 仓库的内容在磁盘上,元数据(用户、组织、Issue、PR、权限、Webhook…)在数据库里。只备文件不备库,恢复出来是个空壳;只备库不备文件,仓库内容全没。两者都要。

3. 硬链接快照 + GFS,而不是每次全量压缩

很多备份脚本每次跑都 tar 压缩成一个全量归档,保留 N 份。问题是 N 份各占一份全量、每次还很吃 CPU。这里改用 rsync 到常驻 mirror + cp -al 硬链接快照:未变化的文件在快照间共享 inode,每天的快照几乎只占增量。保留策略用 GFS(日 7 / 周 8 / 月 12)。

架构

[A 生产机 192.168.1.10]                    [DB 机 192.168.1.30]
  forgejo (rootless) ──连接──────────────────> PostgreSQL :5432
  数据(bind mount):
    /data/docker/forgejo/forgejo  (仓库/附件/app.ini)
    /hdd/forgejo/git/lfs          (LFS,单独大盘)
  pre-hook: pg_dump -> /opt/forgejo-backup/db/
                                          ▲
                                          │  B ssh 拉取(pull)
[B 备份机 192.168.1.20]                    │
  main.sh + engine.sh + conf/ ────────────┘
    /data/backups/forgejo/mirror/      最新镜像
    /data/backups/forgejo/<日期>/      硬链接快照(GFS)

先搞清楚数据在哪

用的是 rootless 镜像,路径和普通镜像不一样,先确认:

# 数据库类型与配置路径
docker exec forgejo sh -c 'cat ${GITEA_APP_INI:-/etc/gitea/app.ini} | grep -iA8 "\[database\]"'
# 卷在宿主机的真实位置
docker inspect forgejo --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

得到的事实:数据库是外部 PostgreSQL(不在容器里);主数据 bind mount 在 /data/docker/forgejo/forgejo;LFS 在独立盘 /hdd/forgejo/git/lfs。

一个小细节:LFS 是第二个 bind mount,挂在容器内 …/git/lfs,但在宿主机上它是另一条路径。所以 rsync 宿主机的主数据目录天然不会带上 LFS——如果你的 LFS 是上游镜像、可重新拉取,正好不用备它。

实现

A 机:数据库导出(pre-hook)

forgejo-backup.sh,由备份机通过 SSH 触发,在 A 上生成 dump。口令放在 A 本地的 forgejo-backup.env(600),不下放到 B。

#!/usr/bin/env bash
set -euo pipefail
FORGEJO_CT="forgejo"
DB_HOST="192.168.1.30"; DB_PORT="5432"; DB_USER="forgejo"; DB_NAME="forgejo"
PG_IMAGE="postgres:17"                       # pg_dump 客户端;报版本不匹配就升 tag
ENV_FILE="/opt/scripts/forgejo-backup.env"   # 只含一行 PGPASSWORD=...,chmod 600
APPINI_HOST="/data/docker/forgejo/forgejo/custom/conf/app.ini"
COMPOSE_DIR="/data/docker/forgejo"
DEST="/opt/forgejo-backup/db"; KEEP=7

mkdir -p "$DEST"; TS=$(date +%F_%H%M); DUMP="$DEST/forgejo_$TS.dump"
docker exec "$FORGEJO_CT" forgejo manager flush-queues || true
docker run --rm --env-file "$ENV_FILE" "$PG_IMAGE" \
  pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -Fc "$DB_NAME" > "$DUMP"
[ -s "$DUMP" ] || { echo "ERROR: dump 为空"; rm -f "$DUMP"; exit 1; }
cp -f "$APPINI_HOST" "$DEST/app.ini.bak" 2>/dev/null || true
cp -f "$COMPOSE_DIR"/docker-compose.y*ml "$DEST/" 2>/dev/null || true
( cd "$DEST" && sha256sum "forgejo_$TS.dump" > "forgejo_$TS.dump.sha256" )
ls -1t "$DEST"/forgejo_*.dump | tail -n +$((KEEP+1)) | while read -r f; do rm -f "$f" "$f.sha256"; done
echo "完成: $DUMP"
用 -Fc(自定义格式)压缩、可灵活恢复。一个反直觉的点:dump 通常很小(几 MB),因为库里只有元数据,仓库内容根本不在库里——别被小体积吓到。

B 机:引擎 engine.sh

读一个 conf,跑「pre-hook → rsync → 校验 → 硬链接快照 → GFS 清理」。

#!/usr/bin/env bash
set -euo pipefail
CONF="$1"; NAME="$(basename "${CONF%.conf}")"
SSH_HOST=""; SSH_USER="root"; SSH_PORT=22; SSH_KEY=""
PRE_CMD=""; BWLIMIT=0; NICE=15
SRC_DIRS=(); EXCLUDES=()
KEEP_DAILY=7; KEEP_WEEKLY=8; KEEP_MONTHLY=12
BACKUP_ROOT="${BACKUP_ROOT:-/data/backups}"
source "$CONF"
[ -n "$SSH_HOST" ] || { echo "[$NAME] 缺 SSH_HOST"; exit 2; }
[ "${#SRC_DIRS[@]}" -gt 0 ] || { echo "[$NAME] 缺 SRC_DIRS"; exit 2; }

BASE="$BACKUP_ROOT/$NAME"; MIRROR="$BASE/mirror"; SNAP="$BASE/$(date +%F)"; mkdir -p "$MIRROR"
SSH_OPT=(ssh -p "$SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
[ -n "$SSH_KEY" ] && SSH_OPT+=(-i "$SSH_KEY"); REMOTE="$SSH_USER@$SSH_HOST"

# 1) 远程 pre-hook(如 pg_dump)
[ -n "$PRE_CMD" ] && { echo "[$NAME] pre-hook"; "${SSH_OPT[@]}" "$REMOTE" "$PRE_CMD"; }

# 2) 逐个源目录增量拉取  SRC_DIRS=("label:/remote/path" ...)
RS_EXCL=(); for e in "${EXCLUDES[@]:-}"; do [ -n "$e" ] && RS_EXCL+=(--exclude "$e"); done
for entry in "${SRC_DIRS[@]}"; do
  label="${entry%%:*}"; path="${entry#*:}"
  [ "$label" != "$entry" ] || { echo "[$NAME] SRC_DIRS 须为 label:/path: $entry"; exit 2; }
  mkdir -p "$MIRROR/$label"
  nice -n "$NICE" rsync -a --delete --bwlimit="$BWLIMIT" "${RS_EXCL[@]}" \
    -e "${SSH_OPT[*]}" "$REMOTE:$path/" "$MIRROR/$label/"
done

# 3) 可选校验:conf 定义 verify() 即生效
if declare -F verify >/dev/null; then ( cd "$MIRROR" && verify ) || { echo "[$NAME] 校验失败"; exit 1; }; fi

# 4) 硬链接快照
rm -rf "$SNAP"; cp -al "$MIRROR" "$SNAP"

# 5) GFS 保留
find "$BASE" -maxdepth 1 -type d -regextype posix-extended -regex '.*/[0-9]{4}-[0-9]{2}-[0-9]{2}
  
  




 | while read -r d; do
  day=$(basename "$d"); age=$(( ($(date +%s)-$(date -d "$day" +%s))/86400 ))
  dow=$(date -d "$day" +%u); dom=$(date -d "$day" +%d); keep=0
  [ "$age" -le "$KEEP_DAILY" ] && keep=1
  { [ "$dow" = 7 ] && [ "$age" -le $((KEEP_WEEKLY*7)) ]; } && keep=1
  { [ "$dom" = 01 ] && [ "$age" -le $((KEEP_MONTHLY*31)) ]; } && keep=1
  if [ "$keep" = 0 ]; then echo "[$NAME] purge $day"; rm -rf "$d"; fi
done
echo "[$NAME] done: $SNAP"

B 机:入口 main.sh

#!/usr/bin/env bash
set -uo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
export BACKUP_ROOT="${BACKUP_ROOT:-/data/backups}"
LOG_DIR="$DIR/logs"; mkdir -p "$LOG_DIR"

# 防并发:同一时刻只跑一个备份
if command -v flock >/dev/null 2>&1; then
  exec 9>"$DIR/.lock"; flock -n 9 || { echo "已有备份在运行"; exit 0; }
fi

run_one() {
  local conf="$1" name; name="$(basename "${conf%.conf}")"
  [ -s "$conf" ] || { echo "跳过 $conf"; return 1; }
  echo "===== $(date '+%F %T') 备份 [$name] ====="
  bash "$DIR/engine.sh" "$conf" 2>&1 | tee -a "$LOG_DIR/${name}_$(date +%F).log"
  local rc=${PIPESTATUS[0]}; [ "$rc" = 0 ] && echo "[$name] OK" || echo "[$name] 失败 rc=$rc"; return "$rc"
}
if [ "${1:-}" ]; then run_one "$DIR/conf/${1%.conf}.conf"; exit $?; fi
fail=0; for c in "$DIR"/conf/*.conf; do run_one "$c" || fail=1; done; exit $fail

一个服务一个 conf

conf/forgejo.conf:

'bash -lc /opt/scripts/forgejo-backup.sh'   # bash -lc 保证 ssh 下能找到 docker
SRC_DIRS=(
  "data:/data/docker/forgejo/forgejo"   # 仓库/附件/app.ini(LFS 在 /hdd,不在此树)
  "db:/opt/forgejo-backup/db"           # pg_dump + app.ini + compose
)
EXCLUDES=( 'git/lfs/' 'log/' 'data/tmp/' )
verify() {
  local latest; latest=$(ls -1t db/forgejo_*.dump 2>/dev/null | head -1)
  [ -n "$latest" ] || return 1
  ( cd db && sha256sum -c "$(basename "$latest").sha256" ) || return 1
}

加一个新服务,就复制一个 conf、改 SRC_DIRS(必要时加 PRE_CMD/verify)即可,main.sh 自动纳入。

定时 + 失败告警

cron 自己不发 SMTP,它把信交给本机的 sendmail。装 msmtp 当 MTA,中转到你邮箱的 SMTP(QQ/163/Gmail 等都行,密码用授权码/应用专用密码,不是登录密码):

0 4 * * * /opt/serverbackup/main.sh >> /opt/serverbackup/logs/cron.log 2>&1 || printf 'Subject: [backup] 失败 %s\n\n%s\n' "$(hostname)" "$(tail -n 30 /opt/serverbackup/logs/cron.log)" | msmtp you@example.com

失败把最后 30 行日志发给你。要发给多人就在 msmtp 后并列多个地址。

踩过的坑

  1. set -e + pipefail 把成功跑成 rc=1。 GFS 清理用了 [ "$keep" = 0 ] && { rm ...; },当快照要保留(keep=1)时这句返回非 0,经 pipefail 让整个 find | while 管道失败、触发 set -e,于是每次成功备份都误报失败。改成 if [ "$keep" = 0 ]; then …; fi 即可。
  2. rootless 镜像路径不同。 配置在 /etc/gitea/app.ini 或 …/custom/conf/app.ini,数据在 /var/lib/gitea,容器本身以 git(1000) 运行,别套用普通镜像的 /data/...。
  3. 口令差点进 git。 恢复手册里写了真实 PG 口令,差点 git push 上去。处理:从文件里脱敏成 -e PGPASSWORD 透传;如果含密的提交还没推送,改写它(git commit --amend)让口令从将推送的历史里消失;已经推过就只能改写远端 + 轮换口令。并补一个 .gitignore 忽略 sshkey/、logs/、*.env、真实 *.conf。
  4. conf 是被 source 的,等同执行脚本。设 600、别写不可信内容。
  5. 恢复演练不能省。 没演练过的备份不算备份。

恢复(要点)

# 1) 文件
rsync -a "$SNAP/data/" /data/docker/forgejo/forgejo/
chown -R 1000:1000 /data/docker/forgejo/forgejo      # rootless 容器内 git 用户
# 2) 数据库(-Fc 用 pg_restore;口令用环境变量传,别写进命令)
export PGPASSWORD='<数据库口令>'
DUMP=$(ls -1t "$SNAP"/db/forgejo_*.dump | head -1)
docker run --rm -i -e PGPASSWORD postgres:17 \
  pg_restore --create --clean --if-exists -h 192.168.1.30 -U forgejo -d postgres < "$DUMP"
# 3) 起服务并验证:能登录、能 clone、Issue/PR/附件正常
docker compose up -d

小结

  • 拉取模式保护备份不被生产端连累;
  • 数据库 dump + 文件 rsync两条腿,缺一不可;
  • 硬链接快照 + GFS 比每次全量压缩省太多;
  • conf 驱动让加服务变成加一个文件;。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论