restic と Cloudflare R2 でバックアップをサーバーの外に置く
はじめに
個人サーバーのバックアップを、サーバーの外にも置くようにしました。送り先は Cloudflare R2、道具は restic です。
これまでも、DB・アプリの設定・アップロードファイルの3種類を、毎朝 cron でバックアップしていました。ただ、保存先はすべて同じブロックボリュームでした。そのボリュームが壊れたり、操作ミスで消したりすると、本体とバックアップを同時に失います。
「バックアップはあるけど、守れていない」状態だったので、サーバーの外にコピーを置くことにしました。
なぜ restic と Cloudflare R2 にしたか
送り先に R2 を選んだ理由は3つです。
- 無料枠に収まる:Standard ストレージは毎月 10GB まで無料です。送るバックアップは約70MB なので、十分に余裕があります。
- 取り出しが無料:R2 はエグレス(ダウンロード)料金がかかりません。いざ復元するときに、料金を気にしなくて済みます。
- サーバーと別の会社:サーバーは Oracle Cloud(OCI)にあります。OCI のアカウントや障害の影響を受けない場所に置きたかったので、別の会社のサービスにしました。ドメインの管理で Cloudflare をもう使っていたのも決め手です。
送る道具には restic を選びました。
- 暗号化が標準:バックアップには .env やパスワード管理のデータも入るので、送る前に暗号化できることが必須でした。
- 重複排除:前回と同じ部分は送らないので、毎日送っても容量がほとんど増えません。
- S3 互換に直接送れる:R2 は S3 互換なので、restic からそのまま使えます。
注意点がひとつあります。R2 のストレージクラスは必ず Standard にしてください。Infrequent Access には無料枠がありません。
できあがった構成
毎朝、既存のバックアップが終わったあとに、その結果をまとめて R2 へ送ります。
| 時刻 | 処理 | 保存先 |
|---|---|---|
| 4:00 | DB のバックアップ | サーバーのブロックボリューム |
| 4:15 | アプリの設定ファイルのバックアップ | 同上 |
| 4:30 | アップロードファイルのバックアップ | 同上 |
| 4:45 | 上の3つを restic で送る | Cloudflare R2 |
R2 側の保持ルールは、日次7世代・週次4世代・月次6世代です。日曜日だけ、データの一部を読み出して壊れていないかを検査します。結果は Uptime Kuma の Push 監視に送り、失敗したら Discord に通知が来ます。
手順
環境は Rocky Linux 9(aarch64)、restic 0.19.1 です。
1. R2 のバケットを作る
Cloudflare のダッシュボードで R2 を有効にして、バケットを作ります。設定は次のとおりです。
- ストレージクラス:Standard
- 公開アクセス:無効のまま(カスタムドメインも r2.dev の URL も使わない)
- ライフサイクルルール:最初から入っている「途中で止まったアップロードを7日後に片付ける」ルールだけ残す
古いバックアップの削除は restic に任せます。R2 のライフサイクルルールでファイルを消すと、restic のデータが壊れるので、削除するルールは追加しません。
2. R2 の API トークンを作る
R2 の画面にある「R2 API トークンの管理」から作ります。権限は「オブジェクトの読み取りと書き込み」、対象は作ったバケットだけに絞りました。さらに、サーバーの IP アドレスからしか使えないように制限しています。
作成直後の画面に、アクセスキー ID とシークレットアクセスキーが表示されます。シークレットはこの画面でしか見られないので、すぐにパスワードマネージャーに保存します。
3. restic を入れる
Rocky Linux 9 では、EPEL に最新版がありました。dnf で入れておけば、更新も自動で入ります。
sudo dnf install -y restic restic version
4. 認証情報のファイルを作る
root だけが読めるファイルに、認証情報をまとめます。値をコマンドに直接書くとシェルの履歴に残るので、エディタで書き込みます。
sudo install -d -m 700 /etc/restic sudo install -m 600 /dev/null /etc/restic/r2.env sudo vi /etc/restic/r2.env
中身は次の5行です。
export AWS_ACCESS_KEY_ID='<アクセスキーID>' export AWS_SECRET_ACCESS_KEY='<シークレットアクセスキー>' export AWS_DEFAULT_REGION='auto' export RESTIC_REPOSITORY='s3:https://<アカウントID>.r2.cloudflarestorage.com/<バケット名>' export RESTIC_PASSWORD='<resticのパスワード>'
restic のパスワードは、なくすと二度と復元できません。パスワードマネージャーに入れたうえで、紙にも控えました。パスワードマネージャーのデータ自体もこのバックアップに入っているので、そこにしか控えがないと、いざというときに取り出せないためです。
5. 初期化して、1回目を送る
sudo bash -c '. /etc/restic/r2.env && restic init' sudo bash -c '. /etc/restic/r2.env && restic backup /mnt/data/db-backups /mnt/data/app-backups /mnt/data/file-backups'
1回目は約1,800ファイル、180MB の処理に5秒ほどでした。実際に R2 に送られたのは約72MB です。ファイルのバックアップがハードリンクの差分スナップショットになっていて、同じファイルが何度も数えられていたのを、restic が1つにまとめてくれました。
復元テストと SELinux のつまずき
バックアップは「戻せること」を確かめて初めて意味があるので、自動化の前に復元を試しました。DB バックアップのフォルダだけを /tmp に戻して、元のファイルと比べます。
sudo bash -c '. /etc/restic/r2.env && restic restore latest --target /tmp/restic-test --include /mnt/data/db-backups'
ファイルは戻ったのですが、restic は最後にエラーで終了しました。
ignoring error for /mnt/data: xattr.LRemove /tmp/restic-test/mnt/data security.selinux: permission denied Fatal: There were 1 errors
原因は SELinux のラベルです。restic はバックアップのときに、ファイルの拡張属性として SELinux のラベル(security.selinux)も記録しています。復元のときにそれを付け直そうとして、SELinux に拒否されていました。ファイルの中身には関係なく、diff -r で比べると元のファイルと完全に一致していました。
対処は、復元のときにこの拡張属性を無視する --exclude-xattr を付けることです。
sudo bash -c '. /etc/restic/r2.env && restic restore latest --target /tmp/restic-test --include /mnt/data/db-backups --exclude-xattr security.selinux'
これでエラーなく復元できました。ラベルは、本来の場所に戻したあとで restorecon を使って付け直します。SELinux が有効なサーバーで restic を使うなら、手順書に書いておくべきポイントだと思います。
自動化:スクリプト、cron、Push 監視
毎日の処理は、シェルスクリプトにまとめて root の cron で動かしています。中でやっていることは次のとおりです。
- 認証情報のファイルが、root の所有で権限 600 になっているかを確認する
restic backupで送るrestic forget --pruneで、保持ルールから外れた世代を消す- 日曜日だけ
restic check --read-data-subset 10%でデータを検査する - 結果を Uptime Kuma に Push する
どこかで失敗したら、その先は実行せずに失敗として Push します。同時に2つ動かないように、flock で排他もかけています。
45 4 * * * /path/to/backup-offsite.sh >> /var/log/offsite-backup-cron.log 2>&1
ひとつ工夫したのが、ホスト名の固定です。restic は、保持ルールをホスト名ごとに適用します。サーバーを作り直してホスト名が変わると、古い世代が別のグループ扱いになり、整理されずに残ってしまいます。そこで、--host に固定の名前を渡すようにしました。
restic backup --host web-server --tag daily <対象のフォルダ> restic forget --host web-server --tag daily --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
手動で動かした2回目は、1回目と中身が同じだったので、送られた量は 0B でした。
つまずいたところ
| つまずき | 原因 | 対処 |
|---|---|---|
| アクセスキー ID とシークレットが表示されない | 通常の Cloudflare API トークンでは、トークンの値しか表示されない | R2 の画面の「R2 API トークンの管理」から作り直す |
| 復元が「Fatal: There were 1 errors」で終わる | SELinux のラベルの付け直しを拒否された | --exclude-xattr security.selinux を付け、戻したあとに restorecon |
| 保持ルールで古い世代が消えない(になりそう) | ホスト名ごとにグループが分かれる | --host に固定の名前を渡す |
| ライフサイクルルールの扱い | R2 側で消すと restic のデータが壊れる | 削除のルールは追加せず、restic forget --prune に任せる |
まとめ
これで、サーバーのディスクが丸ごと壊れても、R2 から DB や設定を取り戻せるようになりました。費用は無料枠の範囲で、作業は半日ほどです。
振り返ると、大事だったのは次の3つでした。
- 送る前に暗号化する。パスワードは、バックアップの外(紙)にも控える
- 自動化の前に、一度は実際に復元してみる
- 失敗に気づける仕組み(Push 監視)を一緒に作る