Share Notes

chundev

View the Project on GitHub latteouka/share-notes

用 restic + rclone 做加密雲端備份:增量原理與實戰踩坑

日期:2026-04-21 技術棧:restic 0.18、rclone 1.73、Google Drive、macOS launchd


TL;DR

要把一個 ~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 核心機制一:Content-Defined Chunking(CDC)

一般直覺:檔案為單位的增量

最直觀的增量備份思路是「哪些檔案改過就重傳」。問題是:

檔案為單位的增量,對大量二進位檔案效率很差。

restic 的做法:把檔案切成 chunk

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

為什麼是 Content-Defined?

「平均 1MB」不是指「每 1MB 切一刀」,而是依內容決定切點。重點是當你在檔案開頭插入一段內容時,後面所有切點不會整體位移

原檔:  [chunk1][chunk2][chunk3]...[chunk10]
插入後:[ new ][chunk1][chunk2][chunk3]...[chunk10]

如果是固定切塊(例如每 1MB),插入內容後所有切點都會往後推 → 每個 chunk 都變不一樣 → 整檔重傳。Rabin fingerprint 算的是「滑動視窗的 hash」,遇到特定 hash pattern 就切一刀;這種切點依內容而定,插入不會造成連鎖位移。

效果:8GB 資料夾每天改動量通常幾百 MB,實際上傳可能只有幾十 MB。


restic 核心機制二:Snapshot 是 Immutable Metadata

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]

這帶來兩個關鍵性質:

1. Snapshot 本身超小

一個 snapshot 只是幾 KB 的 metadata(視檔案數而定)。即使留 100 個 snapshot,metadata 開銷可忽略;真正佔空間的是沒看過的 chunk。所以保留策略可以很大方。

2. 過去的 snapshot 不可變

每次 restic backup新增一個 snapshot,完全不觸碰過去的。即使今天整個資料夾被勒索加密,跑新的備份只會多一份「所有檔案都是密文 hash」的 snapshot;昨天以前的 snapshot 裡的 hash 清單依然指向乾淨的 chunk

這就是為什麼 restic 模型天然防勒索 — 你永遠可以回頭找乾淨版本。


保留策略:forget 的 bucketing 機制

很多人看到這個設定:

restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12

直覺會以為是「最後 7 天每天一份 + 最後 4 週每週一份 + 最後 12 個月每月一份 = 23 份」。實際上:

forget 是 bucketing

restic 把所有 snapshot 依照 day / week / month 分桶:

Bucket 規則
daily 一個 snapshot 屬於「它被建立那一天」的 day bucket
weekly 同理
monthly 同理

--keep-daily 7 意思是「保留最近 7 個『有 snapshot 的 day』,每個 day 只留最新一份」。

同一個 snapshot 可以占多個 bucket

這是關鍵:如果今天只跑一次備份,這個 snapshot 同時是 daily 第一名、weekly 第一名、monthly 第一名,它一份佔三個位置。restic forget 不會重複計數。

所以實際保留的 snapshot 數通常少於參數總和。常見設定(7D + 4W + 12M)實際可能只留 15-20 份。

不要怕 overlap

因為 overlap 不浪費空間,保留策略可以設得寬鬆一點。對敏感資料我建議:

--keep-daily 14 --keep-weekly 8 --keep-monthly 24 --keep-yearly 5

約 2-3 年歷史,實際儲存量通常 30-80GB(以原始資料 ~10GB 為例)。


為什麼搭 rclone 而不直接寫 Google Drive

restic 本身支援幾個後端(S3、SFTP、本地磁碟),但不直接支援 Google Drive。選擇是:

(A)幫 restic 寫一個 Google Drive backend

(B)restic 透過 rclone 當 generic backend

這是典型的 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

三層驗證:daily / weekly / monthly

備份沒做 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) 真正的「備份解不開」問題

為什麼需要 sample restore

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 比對

實戰踩過的坑

坑 1:bash pipefail × SIGPIPE

產生隨機密碼看起來很簡單:

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 風險

坑 2:rclone 預設 OAuth client 的 rate limit

第一次 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:

  1. 建新的 Google Cloud Project(免費)
  2. Enable Google Drive API
  3. OAuth consent screen → 填基本資訊,把自己加進 Test users(很重要,漏這步會 403)
  4. Credentials → Create OAuth client ID → Desktop app
  5. 拿到 client_id + client_secretrclone config edit 把這兩個值填到 Google Drive remote

換完之後配額變成「你自己的 Cloud Project」,個人用根本碰不到上限。

坑 3:Google OAuth App 的 Testing vs Production

申請完 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 對個人沒價值。

坑 4:restic backup 透過 pipe 看不到進度

預設 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

坑 5:誤解 restic ls --long 的 column

寫「挑隨機小檔」的 awk filter 時寫成:

restic ls "$SNAP" --long | awk '$1 ~ /^-/ && $3+0 < 1048576 {print $NF}'

兩個 bug:

  1. $3GID(通常是 20 = staff),不是檔案 size。size 是 $4
  2. $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。


學到的事


參考資料