编辑
2026-06-24
Linux
00

目录

背景
三个设计决定
1. 拉取模式(Pull),不是推送
2. 数据库必须 dump,不能只 rsync 文件
3. 硬链接快照 + GFS,而不是每次全量压缩
架构
先搞清楚数据在哪
实现
A 机:数据库导出(pre-hook)
B 机:引擎 engine.sh
B 机:入口 main.sh
一个服务一个 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 镜像,路径和普通镜像不一样,先确认:

bash
# 数据库类型与配置路径 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。

bash
#!/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 清理」。

bash
#!/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

bash
#!/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
SSH_HOST=192.168.1.10 SSH_USER=root SSH_KEY=/opt/serverbackup/sshkey/a_host PRE_CMD='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 等都行,密码用授权码/应用专用密码,不是登录密码):

bash
apt install msmtp msmtp-mta

/etc/msmtprc(以 QQ 邮箱为例,chmod 600):

defaults logfile /var/log/msmtp.log account default auth on host smtp.qq.com port 465 tls on tls_starttls off from you@example.com user you@example.com password <SMTP授权码>

关键点:cron 的 MAILTO 只在任务有输出时才发信;如果你把输出重定向进日志(>> cron.log 2>&1),cron 就永远收不到、也不发信。所以用「只在失败时发信」的写法更实用:

cron
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. 恢复演练不能省。 没演练过的备份不算备份。

恢复(要点)

bash
SNAP=/data/backups/forgejo/2026-06-17 # 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 驱动让加服务变成加一个文件;。
分享:

本文作者:Casear

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!