目录
占位符约定:生产机 A
192.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 镜像,路径和普通镜像不一样,先确认:
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:
bashSSH_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 等都行,密码用授权码/应用专用密码,不是登录密码):
bashapt 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 就永远收不到、也不发信。所以用「只在失败时发信」的写法更实用:
cron0 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 后并列多个地址。
踩过的坑
set -e+pipefail把成功跑成 rc=1。 GFS 清理用了[ "$keep" = 0 ] && { rm ...; },当快照要保留(keep=1)时这句返回非 0,经pipefail让整个find | while管道失败、触发set -e,于是每次成功备份都误报失败。改成if [ "$keep" = 0 ]; then …; fi即可。- rootless 镜像路径不同。 配置在
/etc/gitea/app.ini或…/custom/conf/app.ini,数据在/var/lib/gitea,容器本身以git(1000) 运行,别套用普通镜像的/data/...。 - 口令差点进 git。 恢复手册里写了真实 PG 口令,差点
git push上去。处理:从文件里脱敏成-e PGPASSWORD透传;如果含密的提交还没推送,改写它(git commit --amend)让口令从将推送的历史里消失;已经推过就只能改写远端 + 轮换口令。并补一个.gitignore忽略sshkey/、logs/、*.env、真实*.conf。 - conf 是被
source的,等同执行脚本。设 600、别写不可信内容。 - 恢复演练不能省。 没演练过的备份不算备份。
恢复(要点)
bashSNAP=/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 许可协议。转载请注明出处!