内网异机备份 conf 驱动
占位符约定:生产机 A192.168.1.10、备份机 B192.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}