chundev
日期:2026-04-21 技術棧:restic 0.18、rclone 1.73、Google Drive、macOS launchd
要把一個 ~8GB 的敏感工作資料夾每天備份到 Google Drive,需要「加密 + 版本化 + 防勒索同步污染 + 自動化」。用 restic 當備份引擎、rclone 當 Google Drive 傳輸層、launchd 當排程。這篇整理 restic 的增量備份原理(Content-Defined Chunking + snapshot immutability)、為什麼這個架構天然防勒索,以及實作時踩到的坑(SIGPIPE、OAuth rate limit、Google App Testing mode 等)。
很多人把雲端同步(Dropbox、MEGA、Google Drive)當備份,但它們解決的是不同問題:
| 特性 | 雲端同步 | 真正的備份 |
|---|---|---|
| 目的 | 多裝置共享最新狀態 | 保留歷史版本 |
| 誤刪 | 同步刪除(雙向操作) | 舊版還在 |
| 勒索加密 | 同步加密檔到雲端 | 舊 snapshot 不受影響 |
| 同事中毒 | 你的本機也被污染 | 隔離 |
所以如果 ~/data 已經透過 MEGA 和同事共用,還需要一份只有自己能寫入、版本化的備份,才能應付「同事中毒 → 加密檔同步到 MEGA → 再同步回本機」這條攻擊鏈。
最直觀的增量備份思路是「哪些檔案改過就重傳」。問題是:
檔案為單位的增量,對大量二進位檔案效率很差。
restic 把每個檔案用 Rabin fingerprint 切成平均 1MB 的 chunk,然後對每個 chunk 算 SHA-256 hash,用 hash 當作「這段內容是否出現過」的 key。
檔案 A (10MB)
├─ chunk1 (hash: abc...)
├─ chunk2 (hash: def...)
├─ chunk3 (hash: 456...)
└─ ... 10 個 chunk
第一次備份:10 個 hash 都是新的 → 10MB 全部上傳
第二次備份(改了中間一頁):
- 9 個 chunk 的 hash 和上次一樣 → 跳過
- 1 個 chunk 是新的 → 只上傳 1MB
「平均 1MB」不是指「每 1MB 切一刀」,而是依內容決定切點。重點是當你在檔案開頭插入一段內容時,後面所有切點不會整體位移:
原檔: [chunk1][chunk2][chunk3]...[chunk10]
插入後:[ new ][chunk1][chunk2][chunk3]...[chunk10]
如果是固定切塊(例如每 1MB),插入內容後所有切點都會往後推 → 每個 chunk 都變不一樣 → 整檔重傳。Rabin fingerprint 算的是「滑動視窗的 hash」,遇到特定 hash pattern 就切一刀;這種切點依內容而定,插入不會造成連鎖位移。
效果:8GB 資料夾每天改動量通常幾百 MB,實際上傳可能只有幾十 MB。
restic 的 snapshot 不是資料的副本,而是「這些 hash 組成這個目錄樹」的一份 metadata 清單。
snapshot abc123
└─ tree
├─ file1 → [hash-a, hash-b, hash-c]
├─ file2 → [hash-d, hash-e]
└─ subdir/
└─ file3 → [hash-f, hash-g]
這帶來兩個關鍵性質:
一個 snapshot 只是幾 KB 的 metadata(視檔案數而定)。即使留 100 個 snapshot,metadata 開銷可忽略;真正佔空間的是沒看過的 chunk。所以保留策略可以很大方。
每次 restic backup 是新增一個 snapshot,完全不觸碰過去的。即使今天整個資料夾被勒索加密,跑新的備份只會多一份「所有檔案都是密文 hash」的 snapshot;昨天以前的 snapshot 裡的 hash 清單依然指向乾淨的 chunk。
這就是為什麼 restic 模型天然防勒索 — 你永遠可以回頭找乾淨版本。
很多人看到這個設定:
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12
直覺會以為是「最後 7 天每天一份 + 最後 4 週每週一份 + 最後 12 個月每月一份 = 23 份」。實際上:
restic 把所有 snapshot 依照 day / week / month 分桶:
| Bucket | 規則 |
|---|---|
| daily | 一個 snapshot 屬於「它被建立那一天」的 day bucket |
| weekly | 同理 |
| monthly | 同理 |
--keep-daily 7 意思是「保留最近 7 個『有 snapshot 的 day』,每個 day 只留最新一份」。
這是關鍵:如果今天只跑一次備份,這個 snapshot 同時是 daily 第一名、weekly 第一名、monthly 第一名,它一份佔三個位置。restic forget 不會重複計數。
所以實際保留的 snapshot 數通常少於參數總和。常見設定(7D + 4W + 12M)實際可能只留 15-20 份。
因為 overlap 不浪費空間,保留策略可以設得寬鬆一點。對敏感資料我建議:
--keep-daily 14 --keep-weekly 8 --keep-monthly 24 --keep-yearly 5
約 2-3 年歷史,實際儲存量通常 30-80GB(以原始資料 ~10GB 為例)。
restic 本身支援幾個後端(S3、SFTP、本地磁碟),但不直接支援 Google Drive。選擇是:
(A)幫 restic 寫一個 Google Drive backend
(B)restic 透過 rclone 當 generic backend
rclone serve restic 或直接用 rclone:<remote>:<path> URL這是典型的 separation of concerns:restic 專心做「備份邏輯」(chunking、加密、snapshot),rclone 專心做「雲端傳輸」(OAuth、重試、配額)。介面是 POSIX-like 的檔案操作(Put / Get / List / Delete)。
寫法:
export RESTIC_REPOSITORY="rclone:gdrive:Backups/<repo-name>"
export RESTIC_PASSWORD_FILE="$HOME/.config/restic/password"
restic backup --verbose=2 --tag daily /path/to/source
備份沒做 restore 驗證 = 沒備份。但驗證也要量力而為,不能每天都跑全量檢查。設計三層:
| 頻率 | 做的事 | 成本 | 能抓的問題 |
|---|---|---|---|
| 每日 | restic backup + forget --prune |
低(只傳變動 chunk) | 備份本身失敗、forget 失敗 |
| 每週 | restic check |
中(下載 index metadata,約 100-300MB) | Repo metadata 不一致、snapshot tree 損壞 |
| 每月 | restic check --read-data-subset=10% + 隨機 sample restore |
高(下載 ~10% data blob) | 真正的「備份解不開」問題 |
check --read-data-subset=10% 驗證 data blob 能解密,但不驗證「snapshot → chunks → 重組成檔案」這條路。做一次「隨機挑個小檔、實際 restore、跟原檔比 SHA-256」可以補完整條路徑。
# 隨機挑一個 <1MB 的檔,實際還原驗證
SNAP=$(restic snapshots --json | python3 -c 'import json,sys; print(json.load(sys.stdin)[-1]["short_id"])')
restic ls "$SNAP" --json | python3 -c '
import json, random, sys
files = [obj["path"] for line in sys.stdin for obj in [json.loads(line)]
if obj.get("type")=="file" and 0 < obj.get("size",0) < 1048576]
print(random.choice(files))
'
# 拿 path 去 restic restore --include "$path",然後 shasum 比對
產生隨機密碼看起來很簡單:
set -euo pipefail
PASS="$(LC_ALL=C tr -dc 'A-Za-z0-9!@#%^&*' </dev/urandom | head -c 64)"
# → 爆 exit 141
為什麼爆:head -c 64 讀滿 64 bytes 就關掉 pipe → tr 收到 SIGPIPE(signal 13)→ 退出 status 128+13=141。平常沒 pipefail 時 pipeline 看最後一個指令的 status(head 成功),但加了 pipefail 之後任何中段指令的非零 status 都會往外冒,接著 set -e 把 script 殺掉。
教訓:任何「無限輸入 + 限量讀取」的 pipe 都要小心 pipefail。改用會自己輸出固定長度的指令:
PASS="$(openssl rand -hex 32)" # 剛好 64 字元,零 pipe,零 SIGPIPE 風險
第一次 backup 跑一跑就開始噴:
rclone: rateLimitExceeded
Save(<data/xxx>) returned error, retrying after 1.4s: 500 Internal Server Error
不是你的 Google Drive 配額問題。rclone 預設用一組「所有 rclone 使用者共享」的 OAuth Client ID,Google 對這個 client 有全域 quota。當全球有很多人同時用 rclone 上傳時,就撞到限流。
解法:去 Google Cloud Console 申請自己的 OAuth Client:
client_id + client_secret,rclone config edit 把這兩個值填到 Google Drive remote換完之後配額變成「你自己的 Cloud Project」,個人用根本碰不到上限。
申請完 OAuth client 做授權時,瀏覽器跳:
「rclone」尚未完成 Google 驗證程序。這個應用程式目前處於測試階段,只有獲得開發人員核准的測試人員可以存取。 發生錯誤 403:access_denied
原因:你的 OAuth consent screen 在 Testing 模式,且你沒把自己加進「Test users」。
兩個選擇:
| 模式 | 誰能授權 | 代價 |
|---|---|---|
| Testing | 只有「Test users」清單裡的 Email | Refresh token 有效期 7 天(但常用就會自動刷新,不影響每日備份) |
| Production | 任何人 | 使用 sensitive scope(如 Drive 全存取)需要 Google 人工審核(幾天到幾週,要提供 privacy policy / demo video / 網域驗證) |
個人用途保持 Testing 就好,把自己 Email 加進 Test users 就能用。Publish 對個人沒價值。
預設 restic backup 接上 | tee 之後,你只會看到開頭訊息然後長時間靜止。
原因:restic 偵測 stdout 是 pipe 時會關掉「原地更新的進度條」,改成「只在關鍵事件印訊息」。--verbose 也只是印「每隔幾秒一次百分比」,不會印每個檔案。
解法:用 --verbose=2(或 -vv),每個檔案都會印:
restic backup --verbose=2 --tag daily "$SOURCE"
# →
# new /path/to/report.pdf
# modified /path/to/notes.md
# processed 1500 files, 2.3 GiB in 0:45
restic ls --long 的 column寫「挑隨機小檔」的 awk filter 時寫成:
restic ls "$SNAP" --long | awk '$1 ~ /^-/ && $3+0 < 1048576 {print $NF}'
兩個 bug:
$3 是 GID(通常是 20 = staff),不是檔案 size。size 是 $4。$NF 是「最後一個空白分隔 token」。路徑含空格的檔案(例如 報告 v2.pdf)會被切斷,拿到的只是 v2.pdf 而不是完整路徑。解法:restic ls --json 然後用 Python 正確解析:
restic ls "$SNAP" --json | python3 -c '
import json, sys, random
files = []
for line in sys.stdin:
obj = json.loads(line)
if obj.get("type") == "file" and 0 < obj.get("size", 0) < 1048576:
files.append(obj["path"])
print(random.choice(files))
'
教訓:處理結構化資料(尤其是帶 unicode / 空格的檔名)時,能用 JSON 就不要用 awk 切 column。