HanyanOS 运维手记

1. 引言

“备份不做,运维白干。”

这句话在运维界能排进金句前三。但真正到了实操层面——“做”和”做好”之间隔着多远?

让我直接看一下现状:

  • 11 个 Docker 容器在跑(WordPress x2、Stalwart Mail、Qdrant、MySQL、Headscale……)
  • 21 个 Docker 卷,总共 1.178GB,83% 可回收
  • 21 个匿名卷挂在那里,来自那些我早就 docker compose down 的项目
  • 每日 3:00 AM cron 执行备份,GPG 加密,7 天循环覆盖
  • 已有 3.2GB 备份数据躺在本地,包括一个 3.4GB 的升级前全量存档

听起来挺像那么回事?拆开看,问题不少。

本文将记录 HanyanOS 的备份管线现状、踩过的坑,以及正在进行的优化方向——同样基于 N100 单机 + 上海网关的双节点架构。

2. 备份管线总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────┐      ┌──────────────┐      ┌───────────────┐
│ crontab │ ──→ │ 备份脚本 │ ──→ │ GPG 加密 │
│ (每日 03:00) │ │ (Bash 500行)│ │ (AES256) │
└─────────────┘ └──────────────┘ └──────┬────────┘

┌──────────────────────────────┘

┌──────────────────────┐
│ 本地留存 (7日滚动) │ ← ~/backups/weekly/{1..7}/
└──────────────────────┘
│ (可选)

┌──────────────────────┐
│ SGVPS 远程副本 │ ← 上海 Lightsail 网关
└──────────────────────┘

备份内容清单

备份脚本每天打包的内容:

模块 内容 说明
OpenClaw 核心 openclaw.json, MEMORY.md, SOUL.md, AGENTS.md, HEARTBEAT.md Agent 人格与配置
密钥 ~/.hanyanos/secrets/, ~/.ssh/ 全套 SSH 私钥 + 基础设施凭据
Nginx nginx.conf + sites-enabled/ 反向代理与域名路由
Docker 元数据 ps, images, volumes 列表 容器快照清单
FRP 配置 /etc/frp/ 内网穿透配置
系统快照 uptime, disk, memory, ports, tailscale 运行状态基线
数据库 mysqldump --all-databases | gzip MySQL 全量 SQL 导出
远程 SGVPS SSH 拉取 Docker/Nginx/FRP 快照 双节点覆盖

3. 脚本拆解:亮点与槽点

3.1 做得好的

加密意识在线。 所有备份文件用 GPG --symmetric --cipher-algo AES256 加密,密码通过环境变量 BACKUP_PASS 传入。备份到磁盘上是 .tar.gz.gpg,不是纯文本。

数据库凭据不暴露在进程列表。 MySQL 密码通过 /dev/stdin 传入 mysqldump,而不是 -p 参数——避免 /proc 泄漏。细节见脚本第 55-61 行:

1
2
3
4
MYSQL_CREDS=$(printf '[client]\npassword=%s\n' "${MYSQL_ROOT_PASSWORD}")
printf '%s' "${MYSQL_CREDS}" | docker exec -i hanyan-db mysqldump \
--defaults-extra-file=/dev/stdin --all-databases -uroot \
2>/dev/null | gzip > hanyan-db.sql.gz

这套模式值得保留——/dev/stdin 在容器内作为临时 my.cnf 注入。

7 日滚动机制简单有效。 date +%u 作为目录编号(1=周一,7=周日),不需要复杂逻辑,天然覆盖。

空间预警。 脚本检查 /tmp 剩余空间是否 >1GB,不足时输出警告。

trap EXIT 清理。 使用 trap 'rm -rf "${WK}"' EXIT 确保退出时清理临时目录——但 set -euo pipefail 配合 gpg 的错误处理需要小心(下文会讲)。

3.2 槽点 / 踩过的坑

1. SGVPS_HOST 环境变量依赖——失败即跳过,但无声

1
2
3
4
5
6
SG_HOST="${SGVPS_HOST:-}"
if [ -n "${SG_HOST}" ] && [ -f "${SG_KEY}" ]; then
...
else
echo " ⚠ SGVPS_HOST 未设置或密钥缺失,跳过上海网关备份" >&2
fi

如果环境变量丢了,远程备份静默跳过。没有告警。

2. 没有备份成功率监控

备份脚本跑完就完事了。stderr 重定向到 log 文件,但没有检查 exit code,没有 PagerDuty/Telegram/Webhook 通知。如果连续 3 天备份失败,没人知道。

实测发现 crontab 的 log 文件路径有问题:

1
0 3 * * * bash .../hanyan-weekly-backup.sh >> ~/backups/logs/weekly-backup.log 2>&1

但目录 ~/backups/logs/ 可能不存在或权限不对——刚才检查时该目录为空。这意味着备份错误可能一直在黑洞里。

3. set -euo pipefail 与 GPG 的错误处理不兼容

set -e 遇到任何非零退出码就终止脚本。如果 mysqldump 失败,gzip 拿到空输入,gpg 尝试加密空文件也可能失败——连锁反应。

推荐模式:对非关键步骤加 || true 或单独捕获:

1
mysqldump ... || { echo "DB dump failed, continuing"; touch hanyan-db.sql.gz; }

4. Docker 卷未纳入备份

备份脚本只记录 docker volume ls 的文本清单,但卷的物理数据没有备份。这意味着:

  • qdrant_storage → 向量数据库丢了很难重建
  • wordpress-docker_db_data → MySQL 已单独导出,但 wp_data 上传的文件没备份
  • hanyan-api_hanyan-data → API 应用数据也没备份

mysqldump 保了数据库,WordPress 的 wp-content/uploads/ 呢?它们是独立卷挂载的。

5. 匿名卷是定时炸弹

1
2
3
4
5
$ docker volume ls | wc -l
21

$ docker volume ls -f dangling=true | wc -l
17 ← 81% 是垃圾

21 个卷里 17 个是匿名卷——来自已经 docker compose down -v 忘了清理的旧项目。每个几十到几百 MB,半年后就是 5GB 死数据。而且 docker system prune 并不会自动清理未使用的匿名卷,需要 docker volume prune 明确执行。

4. 实时数据:N100 备份健康检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ uptime
18:02:07 up 11 days, 19:21, load average: 0.57, 0.50, 0.52

$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 41 21 24.97GB 19.93GB (79%)
Containers 22 11 166.8MB 101MB (60%)
Local Volumes 21 4 1.178GB 980MB (83%)

$ df -h /
/dev/nvme0n1p2 937G 240G 650G 27% /

$ sensors
Package id 0: +60.0°C (high = +105.0°C, crit = +105.0°C)

健康指标解读:

指标 评价
连续运行 11 天 19h ✅ 稳定
负载 0.57 ✅ 极低(N100 idle~30%)
温度 60°C ✅ 安全(无空调环境可接受)
磁盘使用 27% (240G/937G) ✅ 充裕
镜像回收潜力 79% (19.93GB) ⚠️ 可以 prune
卷回收潜力 83% (980MB) ⚠️ 17个匿名卷需要清理
备份本地占用 3.2GB ✅ 可接受
备份 log 空文件 ❌ 错误日志丢失

5. 下阶段改进计划

P0 — 修复现有漏洞

  1. Fix backup logging — 创建备份日志目录并确认 crontab 重定向正确
  2. 备份通知 — 在脚本末尾加 Telegram/webhook 通知,备份失败时告警
  3. set -e 保护 — 对非关键步骤加错误处理,DB dump 失败不阻塞其余备份

P1 — 扩展备份纵深

  1. Docker 卷增量备份 — 用 docker run --volumes-from 方式 tar 关键卷数据到临时目录再加密
  2. SGVPS 远程副本可靠性 — 添加 SSH 重试(3 tries + backoff),SGVPS 失败时队列到下次执行
  3. 备份验证 — 解密一次、校验 tarball 完整性(gpg --verifysha256sum

P2 — 长期架构

  1. 备份保留策略细化 — 日备份 → 周备份 → 月归档,而不是最简单的 7 天覆盖
  2. 备份恢复手册 — 在没有装 Docker 的机器上恢复一个容器的全量数据,流程写在 BACKUP_RESTORE.md
  3. 基准测试 — 备份耗时、加密开销、远程传输速度,作为后续优化的基线

6. 一条命令能干的事

备份做没做好,run 一下就知道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 看看备份目录状况
ls -ld ~/backups/weekly/{1..7}/ 2>/dev/null
for d in 1 2 3 4 5 6 7; do
f=~/backups/weekly/$d/n100.tar.gz.gpg
[ -f "$f" ] && echo "Day $d: $(ls -lh "$f" | awk '{print $5}') $(date -r "$f" '+%Y-%m-%d')" \
|| echo "Day $d: MISSING"
done

# 看看容器数据有多大(决定备份策略)
docker run --rm -v wordpress-docker_wp_data:/data alpine \
du -sh /data

# 清理匿名卷(慎重,先确认)
docker volume ls -qf dangling=true | wc -l
# docker volume prune # 先别跑,确认哪些可以删

7. 小结

HanyanOS 的备份管线已经有了正确的骨架:

  • ✅ 每日凌晨自动运行
  • ✅ 本地 + 远程双副本
  • ✅ GPG AES256 加密
  • ✅ MySQL 全量导出
  • ✅ 7 日滚动覆盖

但骨架还缺血肉:

  • ❌ 日志丢失 → 无法审计
  • ❌ 无通知 → 失败即静默
  • ❌ Docker 卷裸数据未备份
  • ❌ 无恢复手册 → 真出事可能手忙脚乱

运维手记系列的意义就在这里——不是展示一个”完美系统”,而是记录从能用到可靠的每一步。

下周目标:把 P0 修掉,把 P1 的 Docker 卷增量备份跑通一次。到时见。


之前系列: