惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
博客园 - 叶小钗
H
Help Net Security
P
Privacy & Cybersecurity Law Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Help Net Security
Help Net Security
Hugging Face - Blog
Hugging Face - Blog
D
Docker
Security Archives - TechRepublic
Security Archives - TechRepublic
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V2EX - 技术
V2EX - 技术
人人都是产品经理
人人都是产品经理
L
LINUX DO - 最新话题
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Google Online Security Blog
Google Online Security Blog
博客园 - 聂微东
WordPress大学
WordPress大学
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Latest news
Latest news
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
B
Blog RSS Feed
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Blog — PlanetScale
Blog — PlanetScale
C
Cisco Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Know Your Adversary
Know Your Adversary
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
爱范儿
爱范儿
Webroot Blog
Webroot Blog
宝玉的分享
宝玉的分享
Google DeepMind News
Google DeepMind News
D
DataBreaches.Net
Recent Commits to openclaw:main
Recent Commits to openclaw:main
The Register - Security
The Register - Security
Simon Willison's Weblog
Simon Willison's Weblog
A
Arctic Wolf
Scott Helme
Scott Helme
The Last Watchdog
The Last Watchdog
Y
Y Combinator Blog
Last Week in AI
Last Week in AI
S
Securelist
Cloudbric
Cloudbric
G
GRAHAM CLULEY
M
MIT News - Artificial intelligence
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
V
Visual Studio Blog
S
Schneier on Security
Engineering at Meta
Engineering at Meta

Casear的技术栈

Ubuntu 26.04 装 NVIDIA 580 Razer_GRUB_Theme Fcitx5 输入法配置:切换中文输入法 服务器 zsh 环境配置记录:Zim + p10k + eza + zoxide # ThinkPad T490s Linux 指纹模块启用指南(KDE vanblog使用picgo图床的完整部署教程 vanblog备份恢复评论数据 如何配置一个虚拟显示器,以便远程没有显示器的系统 解决 OpenConnect VPN 由于 JScript 引擎丢失导致的 Error 1 修复 Fuck 2025 ATT&CK红队评估实战靶场二 一言 解决kde环境下切换登陆屏幕(SDDM)后显示异常 rss Linux实用软件分享 Kubuntu 上安装 显卡驱动 和 EnvyControl kubuntu 搜狗输入法安装问题解决 终端工具的选择 chatlog 青龙面板+wxBotWebhook实现自动推送天气 mac 安装 第三方应用问题 zsh 安装与配置,使用 oh-my-zsh 美化终端 pve虚拟机硬盘直通 tldr-pages 安装 pve安装Arch-linux Arch-linux安装桌面环境 提问的艺术极简板 Waline 服务端独立部署解决方案 记-先锋书店 api开发-更优雅的[规范响应数据] sqli 1-7关 Nginx Proxy Manager安装及避坑指南 Xiaomi-Mall-Home kali设置为中文 vscode连接docker查看代码 DVWA--SQL Injection(Blind)(盲注Python脚本) 使用Hexo生成大量页面时卡死问题解决 在Ubuntu上搭建Fivem服务器 互联网档案计划(Internet Archive) NEZHA监控面板美化 哪吒监控配置 CentOS7安装MySQL8.0 burpsuite ca证书安装 powerShell script 你一句春不晚,我就到了真江南 DVWA靶场--XSS(Reflected)教程 DVWA靶场--JavaScript Attacks (前端攻击) 强网杯2023 谍影重重2.0 wp shell脚本备份 MySQL数据库 青龙面板定时规则 异地容灾备份系统两地三中心 转载 关于躺平 python打包可执行文件 python定义函数 MySQL8.0以上PHP连接问题
内网异机备份 conf 驱动
casearx@foxm · 2026-06-24 · via Casear的技术栈

占位符约定:生产机 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

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 等都行,密码用授权码/应用专用密码,不是登录密码):

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 就永远收不到、也不发信。所以用「只在失败时发信」的写法更实用:

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

恢复(要点)

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 驱动让加服务变成加一个文件;。