技術

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:00DB のバックアップサーバーのブロックボリューム
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 で動かしています。中でやっていることは次のとおりです。

  1. 認証情報のファイルが、root の所有で権限 600 になっているかを確認する
  2. restic backup で送る
  3. restic forget --prune で、保持ルールから外れた世代を消す
  4. 日曜日だけ restic check --read-data-subset 10% でデータを検査する
  5. 結果を 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 監視)を一緒に作る

この記事を書いた人

雪白深冬

TRPG とゲームが好きで、遊ぶための道具を作ったりサーバーを動かしたりしています。

← 記事一覧へ