update 2025-12-18:初始方案。SSH 免密 + Rsync 增量归档 + Cron
update 2026-01-13:增加 Healthchecks.io 监控
TL;DR
为了防止有仓库写权限的 LLM Agent 意外删库等造成的数据损失,我们使用 rsync 镜像同步(--delete)配合简易版本控制(--backup --backup-dir)进行数据备份。
在这类自动备份任务中,失败的后果不是立即可见的,普通的通知机制无法在发生服务器下线等意外情况时生效,为此我们使用 Healthchecks.io 实现类似 Dead man’s switch 的监控,在无需关注成功通知的前提下也能信任备份服务正常运行。
Context
要让 Codex 等 Agent 发挥基本作用,需要其具有 workspace-write 权限(Agent 模式) ,导致其具有删库能力。我工作中的项目数据未被 git 追踪;代码使用 git 追踪并有 github 备份,但我经常忘记 push 代码。这导致这些数据有丢失可能。可以将数据通过 rsync 备份到不同的主机或者同主机的不同位置进行防范。
前置环境 (SSH & 权限)
SSH 免密
Cron 无法交互输入密码,需生成无密码专用 Key。
ssh-keygen -t ed25519 -f ~/.ssh/id_backup -N ""ssh-copy-id -i ... 分发公钥。- 配置
~/.ssh/config 指定 IdentityFile。
占位:使用 fallocate 创建大文件预占空间,防止无配额系统被填满。自己有需要再释放
例如 fallocate -l 200G reserved_storage_1_200G.img
修改权限为只读 (444 / -r–r–r–)
chmod 444 reserved_storage_*.img
备份脚本 (Rsync)
核心逻辑:rsync -avh --delete --backup --backup-dir=...
核心设计考量
rsync 参数:考虑到实验数据主要是大量媒体文件、权重,且局域网高带宽传输,不开启压缩;小文件为主,所以不开启 --partial
镜像备份 +Versioning:rsync 默认是 update 形式,我们使用 --delete flag 进行镜像备份。另外利用 --backup-dir 将被修改/删除的文件移至 _history/日期 目录进行简单 versioning 以达成防误删。
性能保护:使用 ionice -c3 (Idle) 运行。磁盘空闲时全速,有训练任务时自动让路。目前初步试水,如果频繁发现同步无法完成,改用 c2+ 低优先级。
注意
- 归档位置:History 目录必须位于 同步目标目录之外。若放在同步目录内,会导致递归备份自身的死循环灾难。
- 末尾斜杠:Rsync 对
src/ (内容) 和 src (目录) 极其敏感。
其他特性
- 文件锁:
flock -n,防止上一次没跑完导致堆积。 - 路径检查:脚本已强制检查
SRC 和 DEST 末尾的 /,防止层级错乱。漏写斜杠直接报错退出,但不自动修正,以防止不可预见的问题。 - 绝对路径:Cron 环境缺失
$PATH,脚本内所有路径(日志、锁文件)必须写死绝对路径。 - 全链路报警 (Slack):覆盖运行失败、锁冲突(任务堆积)、源目录丢失(挂载掉线)场景。
- 网络防僵死:配置 SSH
ConnectTimeout 与心跳机制,断网即报错而非无限挂起。 - 观察期逻辑:设定截止日期,在此之前 " 成功 " 也发送通知(金丝雀观察),过期自动失效。
- 源目录预检:防止因本地挂载丢失导致的远程误删。
以下是 2025-12-18 版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
| #!/bin/bash
# ==========================================
# 1. 全局配置区域 (All Configurations)
# ==========================================
# --- 路径配置 ---
# [注意] SRC_DIR 末尾必须带斜杠 / (表示同步目录内的内容,而不是目录本身)
SRC_DIR="/path/to/local/source/"
REMOTE_USER="your_remote_user"
REMOTE_HOST="remote.backup-server.com" # 或者填写 IP: 192.168.x.x
REMOTE_BASE="/mnt/backup_drive/user_backups"
# [注意] REMOTE_DEST_DIR 末尾建议带斜杠 / (保持语义清晰)
REMOTE_DEST_DIR="${REMOTE_BASE}/workspace_mirror/"
# 历史目录 (基于日期)
TODAY=$(date +%Y-%m-%d)
BACKUP_HISTORY_DIR="${REMOTE_BASE}/workspace_history/${TODAY}"
# --- 日志与锁文件配置 ---
LOG_DIR="/home/your_username/scripts/sync/logs"
DAILY_LOG="${LOG_DIR}/${TODAY}.log"
SKIPPED_LOG="${LOG_DIR}/skipped.log"
LOCK_FILE="/tmp/rsync_backup.lock"
HOSTNAME=$(hostname)
# --- Slack 报警配置 ---
# 请替换为您自己的 Webhook URL
SLACK_URL="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
# 设定截止日期 (格式: YYYYMMDD)。例如:想观察到年底为止
OBSERVATION_END_DATE=20251231
# ==========================================
# 2. 初始化与锁 (Init & Lock)
# ==========================================
# 必须先创建日志目录
mkdir -p "$LOG_DIR"
# 防止重入锁 (Singleton Pattern)
exec 9>"$LOCK_FILE"
if ! flock -n 9 ; then
echo "$(date): Backup already running. Skipped." >> "$SKIPPED_LOG"
# [新增] 锁冲突报警:如果上一次任务卡死超过24小时,必须通知
MSG="{\"text\":\"⚠️ *Backup SKIPPED (Locked)* \nHostname: ${HOSTNAME}\nTime: $(date)\nReason: Previous backup is still running (Zombie process?)\"}"
curl -s -X POST -H 'Content-type: application/json' --data "$MSG" "$SLACK_URL" || true
exit 200
fi
# ==========================================
# 3. 安全检查 (Safety Checks)
# ==========================================
# 源目录是否存在
# 如果 workspace 挂载掉线,rsync 会视为空目录从而删除远程文件,必须拦截
if [[ ! -d "$SRC_DIR" ]]; then
echo "CRITICAL ERROR at $(date): SRC_DIR ($SRC_DIR) does not exist! Aborting." >> "$DAILY_LOG"
# 发送致命错误报警
MSG="{\"text\":\"🚨 *Backup CRITICAL!* \nHostname: ${HOSTNAME}\nError: Source Directory Missing! Check Mounts.\"}"
curl -s -X POST -H 'Content-type: application/json' --data "$MSG" "$SLACK_URL" || true
exit 1
fi
# 源目录必须以 / 结尾
if [[ "$SRC_DIR" != */ ]]; then
echo "CRITICAL ERROR at $(date): SRC_DIR ($SRC_DIR) missing trailing slash '/'. Aborting." >> "$DAILY_LOG"
exit 1
fi
# 目标目录必须以 / 结尾
if [[ "$REMOTE_DEST_DIR" != */ ]]; then
echo "CRITICAL ERROR at $(date): REMOTE_DEST_DIR ($REMOTE_DEST_DIR) missing trailing slash '/'. Aborting." >> "$DAILY_LOG"
exit 1
fi
# ==========================================
# 4. 执行业务逻辑 (Execution)
# ==========================================
echo "Start backup at $(date)" >> "$DAILY_LOG" # 改为追加,保留上面的报错信息的可能性
# ionice -c3: 磁盘空闲才运行
ionice -c3 rsync -avh \
-e "ssh -o ConnectTimeout=10 -o ServerAliveInterval=60 -o ServerAliveCountMax=3" \
--delete \
--backup \
--backup-dir="$BACKUP_HISTORY_DIR" \
"$SRC_DIR" \
"${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DEST_DIR}" >> "$DAILY_LOG" 2>&1
EXIT_CODE=$?
echo "End backup at $(date) with exit code $EXIT_CODE" >> "$DAILY_LOG"
# ==========================================
# 5. 清理旧日志 (Cleanup)
# ==========================================
# 只删除 "202x-xx-xx.log" 格式的文件
find "$LOG_DIR" -name "????-??-??.log" -type f -mtime +30 -delete
# ==========================================
# 6. Slack 报警 (Slack Alerts)
# ==========================================
# --- A. 失败报警 ---
if [ $EXIT_CODE -ne 0 ]; then
MSG="{\"text\":\"🚨 *Backup FAILED!* \nHostname: ${HOSTNAME}\nTime: $(date)\nError Code: $EXIT_CODE\nLog: $DAILY_LOG\"}"
# [优化] 增加 || echo ... 防止 curl 失败导致静默
curl -s -X POST -H 'Content-type: application/json' --data "$MSG" "$SLACK_URL" || echo "WARNING: Slack notification failed" >> "$DAILY_LOG"
fi
# --- B. 观察期成功通知 ---
CURRENT_DATE_NUM=$(date +%Y%m%d)
if [ $EXIT_CODE -eq 0 ] && [ "$CURRENT_DATE_NUM" -le "$OBSERVATION_END_DATE" ]; then
MSG="{\"text\":\"✅ *Backup SUCCESS* (Observation Period)\nHostname: ${HOSTNAME}\nTime: $(date)\n\nℹ️ Valid until $OBSERVATION_END_DATE\"}"
curl -s -X POST -H 'Content-type: application/json' --data "$MSG" "$SLACK_URL" || echo "WARNING: Slack notification failed" >> "$DAILY_LOG"
fi
|
定时任务 (Cron)
使用 crontab -e 编辑(严禁只敲 crontab,会清空配置)。
0 4 * * * /absolute/path/to/script.sh (务必使用绝对路径)。
script 记得给执行权限
首次运行时建议在 tmux 中手动运行,配合 tail -f log 监控,防止终端断开中断传输。
还可以在 Crontab.guru - The cron schedule expression generator 查看 crontab 含义

Deadman’s Switch
(updated 2026-01-13)
dead man’s switch 有时也称 dead hand switch,死手开关。
在计算机中,类似的机制接受心跳包来检测某一对象的存活,比如每日的自动备份任务完成通知,并在停止收到心跳一段时间后认为对象 “Late” 或者 “down”(借用 Healthchecks.io 的工作方式)并发送通知。
为什么我需要 dead man’s switch
我想要确保备份任务存活。
我可以配置成功通知并每日观察,但我不想每日都主动查看通知。
我可以配置失败通知,但如果失败通知本身的发送机制比如 cron job 本身失效或服务器 down,我无法得知。
为什么我需要外部的 dead man’s switch 服务
dead man’s switch 依然需要一个服务来最终负责发送通知
我可以在本机或者局域网任何机器上运行类似的 dead man’s switch,但我难以信任我的局域网设备的可用性。
本质上,我更信任外部服务的可用性。而我认为他们更易于配置。(我从信任自己运行的服务可用变成信任该外部服务可用,这里的信任换个词说是 bet)。所以我决定使用 Healthchecks.io。
设置 dead man’s switch
最基本的 hc.io 预期在特定时间间隔收到 ping。如果超过时间但仍未收到,对象被认为 “Late”。如果再超过缓冲时间(grace)还未收到 ping,对象被认为 “down”,你将收到 hc.io 的通知。
对于我们的备份这种长时间运行的任务,可以在任务开始时发送 /start。此时,grace 被用作运行的最长允许时间,超时也将收到通知。可以认为 /start 不是一个成功 ping,它只是通知任务开始。
它还允许传递退出码,非零退出码被视为失败,你也将直接收到通知。
因此我们在任务开始时发送 /start,并写一个 finish 函数覆盖所有的退出路径,负责传递退出码给 hc.io 后退出。Coding Agent 可以很好的完成这个更改工作,所以我没更新文章代码。
然后我设置 hc.io 的 grace 为 4h, 比我已知的最长备份时间(2.5h)长一些。
这个系统目前跑了一周,看起来还不错。
可选:NFS 挂载(未实际测试)
若需在计算节点直接浏览备份(非必须,仅方便)。
- Server 端:
/etc/exports。注意 all_squash 和 anonuid 参数以解决 UID 不一致导致的权限问题。 - Client 端:
mount -t nfs。 - 对比:相比 SMB/NAS,NFS 在 Linux 间传输更高效,但不适合 Windows 访问。