
アプリケーション本体はGitHubに置き、Dockerの設定もリポジトリに残っている。サーバーが壊れても再構築できるつもりでいる。しかし、ユーザー登録、決済履歴、投稿データ、設定値といった本当に失うと困るものは、たいていデータベースの中にしかない。
しかも、バックアップを手動で取る運用は長続きしない。最初の数日は忘れないが、機能追加や問い合わせ対応が始まると、バックアップは静かに消える。障害が起きた日に限って、最後のバックアップが数週間前だった、という理不尽な仕様が完成するわけだ。
そこで使いやすいのが、S3互換のオブジェクトストレージであるCloudflare R2だ。毎月10GBまでのストレージ保存が無料で、外部へのデータ転送も無制限で無料。MySQLやPostgreSQLのダンプファイルを定期的に送り、古い世代を自動削除するところまで組める。
ただし、無料という言葉だけを見て構成すると、別の罠に落ちる。R2はバックアップ専用サービスではないし、ダンプの整合性や復元手順まで勝手に面倒を見てくれるわけでもない。結局のところ、費用を抑えながら壊れにくい運用を作るには、保存先、転送方法、世代管理、復元確認を分けて考える必要がある。
個人開発のバックアップでR2が扱いやすい理由
個人開発のサービスでは、バックアップの保存先に求める条件はそれほど複雑ではない。
- DBサーバーと別の場所に保存できる
- 定期処理を自動化できる
- S3向けの既存ツールが使える
- 月額費用が読みやすい
- 障害時に手元へ取り出せる
Cloudflare R2は、この条件にかなり素直に当てはまる。
R2はAmazon S3互換APIを提供しているため、専用の特殊なSDKに縛られない。AWS CLI、boto3、rcloneなど、S3向けに使われてきたツールをそのまま選択肢にできる。個人開発では、採用サービスが増えるほど管理画面と認証情報と請求書が増える。似たような機能を持つ別のバックアップサービスをさらに契約するより、既存のコマンドで扱える保存先を一つ増やすほうが、後々の負担は少ない。
R2の無料枠は、保存容量が毎月10GBまで。さらに、データ転送、いわゆるエグレスは無制限で無料になっている。Class A操作は月100万回、Class B操作は月1,000万回まで無料だ。
DBのバックアップで主に気にするのは、保存容量と書き込み回数である。毎日1回、あるいは数時間に1回ダンプをアップロードする程度なら、通常は操作回数が問題になる構成ではない。個人アプリのバックアップで、1日に何十万回もオブジェクトを書き換えることは考えにくい。
保存容量については、10GBを超えた分に料金が発生する。超過分は1GBあたり月0.015ドル。つまり、無制限に無料という話ではない。動画や画像の原本を大量に置く用途ではなく、DBダンプを世代管理する用途なら、古いファイルを削除する仕組みまで組み込むことで、無料枠内に収めやすい。
R2で無料になるのは「保存先の条件」であって、バックアップ運用そのものではない。ダンプ、認証、世代管理、復元確認は自分で設計する必要がある。
ここを誤解すると、R2にファイルが存在するだけで安心してしまう。バックアップは、ファイルがあることではなく、必要なときに戻せることが重要だ。ファイル名が毎回上書きされている、途中までのダンプが残っている、認証キーを失っている、復元方法を誰も覚えていない。このあたりは、保存先がCloudflareであろうと自宅のNASであろうと同じように起きる。
R2の無料枠で注意する数字
| 項目 | 無料枠 | 超過時の扱い |
|---|---|---|
| ストレージ保存 | 月10GBまで | 1GBあたり月0.015ドル |
| エグレス | 無制限 | 無料 |
| Class A操作 | 月100万回 | 100万回あたり4.50ドル |
| Class B操作 | 月1,000万回 | 100万回あたり0.36ドル |
| Wrangler経由の単一アップロード | 1回315MBまで | 大容量はAWS CLIやrcloneを利用 |
この表から分かるとおり、通常のDBバックアップで先に気にするべきなのは操作回数より保存容量だろう。毎日フルダンプを残すと、データベースのサイズ次第では無料枠を早めに使い切る。反対に、数十MB程度のダンプを一定期間だけ残す運用なら、かなり小さなコストで済む。
バックアップ全体の構成を分解する
個人開発のDBバックアップをR2へ送る構成は、次の四つに分けると考えやすい。
1. データベースからダンプファイルを生成する
2. 生成したファイルをR2へアップロードする
3. 成功したファイルだけを残し、古い世代を削除する
4. 定期的に復元できることを確認する
この順番を飛ばして、いきなり転送コマンドだけを書くと、運用開始後に困る。たとえば、mysqldumpが失敗したのに、空のファイルや途中までのファイルをR2へ送ってしまう構成だ。アップロード自体は成功しているので、監視上は正常に見える。復元しようとした段階で初めて壊れていることに気づく。
ここには、個人開発でよくある理不尽な仕様がある。シェルスクリプトは、コマンドを並べただけでは安全な処理にならない。終了ステータスを見ずに次の処理へ進めば、失敗を成功として扱う。
そのため、最低限次のような方針を取る。
- シェルスクリプトではエラー時に停止する
- 一時ファイルへ出力し、ダンプ完了後に正式なファイル名へ変更する
- ダンプ生成が成功した場合だけアップロードする
- アップロードが成功した場合だけローカルの一時ファイルを削除する
- R2上のファイル名に日時を含め、上書きを避ける
- 失敗時にログを残し、通知できるようにする
ファイル名は、たとえば次のような形式にすると扱いやすい。
mysql-2026-08-07-030000.sql.gz
日時を含めておけば、同じバケット内に複数世代を保存できる。圧縮したファイルを使うなら、ダンプ作成時に標準出力を圧縮し、その圧縮ファイルをアップロードすればよい。
ただし、圧縮率はデータの内容によって変わる。テキスト中心のテーブルなら小さくなるが、すでに圧縮済みのデータやバイナリが多い場合は、それほど減らない。ここでも「圧縮すれば必ず無料枠に収まる」という都合のいい話にはならない。
MySQLをR2へ自動バックアップする
MySQLやMariaDBを使っている個人アプリなら、まずはmysqldumpを使う構成が分かりやすい。
Docker ComposeでMySQLを動かしている場合、バックアップ処理は次のいずれかの場所に置くことになる。
- DBコンテナとは別のバックアップ用コンテナ
- ホストOSのcron
- アプリケーションサーバー上の定期実行
- GitHub Actionsなどの外部ジョブ
現実的には、DBと同じサーバー上でダンプを作り、R2へ送る方法が手軽だ。ただし、DBサーバーとバックアップファイルの生成場所が同じだと、サーバーそのものが壊れたときにダンプも失う。R2へ転送するところまで完了して初めて、別の場所に退避できたことになる。
AWS CLIでR2へ接続する
R2はS3互換APIなので、AWS CLIを利用できる。R2用のAPIトークンを作成し、アクセスキーとシークレットキーを環境変数に設定する。
export AWS_ACCESS_KEY_ID="R2のアクセスキー"
export AWS_SECRET_ACCESS_KEY="R2のシークレットキー"接続先は、CloudflareアカウントのアカウントIDを含むR2エンドポイントになる。
R2_ENDPOINT="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
R2_BUCKET="my-backup-bucket"認証情報は、スクリプトへ直接書かない。Gitリポジトリにコミットするのは論外で、サーバー上の.envに置く場合も、権限を絞っておく。バックアップ用のキーには、必要なバケットだけへアクセスできる権限を付ける。何でも操作できる管理者キーをcronから使うのは、便利だが荒っぽすぎる。
MySQL用のバックアップスクリプト
次は、MySQLのダンプをgzip圧縮してR2へ送る基本形だ。実際の環境では、DBホスト名やユーザー名を環境変数へ置き換える。
#!/usr/bin/env bash
set -Eeuo pipefail
BACKUP_DIR="/var/backups/mysql"
DATE="$(date -u +%Y-%m-%d-%H%M%S)"
TMP_FILE="${BACKUP_DIR}/mysql-${DATE}.sql.gz.tmp"
FINAL_FILE="${BACKUP_DIR}/mysql-${DATE}.sql.gz"
R2_ENDPOINT="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
R2_BUCKET="my-backup-bucket"
mkdir -p "${BACKUP_DIR}"
mysqldump \
--single-transaction \
--routines \
--triggers \
--events \
-h "${MYSQL_HOST}" \
-u "${MYSQL_USER}" \
-p"${MYSQL_PASSWORD}" \
"${MYSQL_DATABASE}" \
| gzip > "${TMP_FILE}"
mv "${TMP_FILE}" "${FINAL_FILE}"
aws s3 cp \
"${FINAL_FILE}" \
"s3://${R2_BUCKET}/mysql/$(basename "${FINAL_FILE}")" \
--endpoint-url "${R2_ENDPOINT}"
rm -f "${FINAL_FILE}"--single-transactionは、InnoDBを使っている環境で、テーブルを長時間ロックせずにダンプを取りやすくするための指定だ。ただし、ストレージエンジンやテーブル構成によって挙動は変わる。すべての環境で無条件に同じ結果になる魔法のオプションではない。
--routines、--triggers、--eventsを付けているのは、テーブルデータだけでは復元できないケースがあるからだ。ストアドプロシージャ、トリガー、イベントを使っているなら、これらもバックアップ対象に含める必要がある。使っていない場合でも、将来の構成変更で抜け落ちることがあるため、明示しておくほうが判断しやすい。
一時ファイルの拡張子を.tmpにしているのも意味がある。ダンプの途中でプロセスが落ちた場合、正式な.sql.gzとして見えないようにするためだ。mvが実行されたファイルだけを完成品として扱う。力技だが、途中ファイルを完成品と勘違いしないためには、こういう地味な処理が効いてくる。
なお、パスワードをコマンドラインに直接渡す方法は、プロセス一覧に表示される可能性がある。簡易的な構成では動くが、共有サーバーや権限の厳しい環境では、MySQLの設定ファイルや専用の認証方法を検討したほうがいい。個人開発だから雑でいい、ではなく、個人開発だからこそ自分が後から見ても分かる形にしておく。
cronで定期実行する
毎日午前3時に実行するなら、cronには次のように登録できる。
0 3 * * * /opt/scripts/backup-mysql-to-r2.sh >> /var/log/backup-mysql.log 2>&1ただし、サーバーのタイムゾーンがUTCなのか日本時間なのかは確認しておく。深夜にバックアップを走らせたつもりが、アクセスの多い時間帯にDBへ負荷をかけていた、という事故は珍しくない。
また、cronは失敗しても画面に赤い警告を出してくれない。ログを残すだけで満足せず、終了コードを監視する仕組みや、失敗時の通知を用意する。通知先はメールでもチャットでもよい。毎回通知すると今度は通知を見なくなるので、成功通知ではなく失敗通知だけにするほうが扱いやすい。
PostgreSQLのバックアップでは形式を選ぶ
PostgreSQLでは、データ量や復元方法に応じてpg_dumpやpg_dumpallを使い分ける。
pg_dumpは通常、特定のデータベースを対象にする。スキーマ単位やテーブル単位で扱いやすく、復元時の柔軟性も高い。一方、pg_dumpallはデータベースクラスタ全体の情報を扱う用途に向いている。ロールやデータベースの作成文も含めたい場合に候補になる。
個人アプリで一つのデータベースだけを運用しているなら、まずはpg_dumpで十分なことが多い。ただし、ユーザー権限や複数データベースの構成まで戻したい場合は、pg_dumpall --globals-onlyなどを組み合わせる設計も必要になる。
PostgreSQL用の基本スクリプト
#!/usr/bin/env bash
set -Eeuo pipefail
BACKUP_DIR="/var/backups/postgres"
DATE="$(date -u +%Y-%m-%d-%H%M%S)"
TMP_FILE="${BACKUP_DIR}/postgres-${DATE}.dump.tmp"
FINAL_FILE="${BACKUP_DIR}/postgres-${DATE}.dump"
R2_ENDPOINT="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
R2_BUCKET="my-backup-bucket"
mkdir -p "${BACKUP_DIR}"
pg_dump \
-Fc \
-h "${POSTGRES_HOST}" \
-U "${POSTGRES_USER}" \
-d "${POSTGRES_DB}" \
> "${TMP_FILE}"
mv "${TMP_FILE}" "${FINAL_FILE}"
aws s3 cp \
"${FINAL_FILE}" \
"s3://${R2_BUCKET}/postgres/$(basename "${FINAL_FILE}")" \
--endpoint-url "${R2_ENDPOINT}"
rm -f "${FINAL_FILE}"-Fcは、PostgreSQLのカスタム形式でダンプする指定だ。復元時にpg_restoreを使う形式で、特定のテーブルだけを戻す、並列で復元するといった操作をしやすい。
一方、SQLテキストとして保存したいなら、gzipと組み合わせる構成もある。
pg_dump \
-h "${POSTGRES_HOST}" \
-U "${POSTGRES_USER}" \
-d "${POSTGRES_DB}" \
| gzip > "${TMP_FILE}"形式の選び方は、保存サイズだけで決めないほうがいい。障害時にどのコマンドで復元するのか、テーブル単位の復元が必要なのか、別バージョンのPostgreSQLへ移行する可能性があるのか。復元側の都合を先に決めると、ダンプ形式も自然に決まる。
バックアップ形式は「保存できるか」ではなく、「深夜の障害対応中に復元できるか」で選ぶ。ここを後回しにすると、復元時に形式の説明書を読み始めることになる。
Docker環境では、どこでバックアップを実行するか
LaravelやWordPressをDocker Composeで動かしていると、バックアップ処理をどのコンテナへ入れるかで悩むことになる。
アプリケーションコンテナへAWS CLIやデータベースクライアントを全部詰め込む方法は、動かすだけなら簡単だ。しかし、Webアプリのイメージにバックアップ用ツールまで増やすと、イメージの責務が曖昧になる。WordPressのコンテナにMySQLクライアントとAWS CLIを追加し、さらにcronまで動かし始めると、最初は便利でも、数か月後には誰も触りたくないコンテナになる。
構成としては、次の三つが現実的だ。
ホストOSからDBコンテナへ接続する
ホスト側にmysqldumpやpg_dump、AWS CLIを入れ、コンテナのポートへ接続する方法だ。構成が少なく、cronも使いやすい。
ただし、DBのポートを外部公開している場合は、ファイアウォールやバインドアドレスの設定に注意が必要だ。バックアップのためにDBをインターネットへ公開する必要はない。ホストからだけ接続できればよい。
専用のバックアップコンテナを作る
バックアップ用のイメージに、必要なクライアントとAWS CLIを入れる方法だ。処理を分離でき、アプリケーションのイメージが汚れない。
cronをコンテナ内で動かすこともできるが、コンテナでcronを動かすための設定が増える。実行タイミングをホスト側のcronに任せ、docker compose run --rm backupのように一回限りのコンテナとして起動する方式も扱いやすい。
外部のジョブ実行基盤を使う
GitHub ActionsなどからDBへ接続してバックアップする方法もある。ただし、DBを外部から接続可能にする必要が出やすい。VPNや踏み台、IP制限を考えると、個人開発の小規模サービスでは構成が重くなる。
結局のところ、最初の構成ではホストのcron、または専用バックアップコンテナが無難だろう。自動化のために運用基盤を複雑化すると、バックアップ処理そのものが新しい障害点になる。便利な道具を増やすほど、壊れる場所も増える。これは個人開発の悲しい定理である。
R2への転送にWranglerだけを使うべきか
CloudflareのCLIであるWranglerからR2へオブジェクトを保存することもできる。Cloudflare環境との統一感はあるし、Cloudflare WorkersやD1と組み合わせる場合には便利だ。
ただし、Wrangler経由の1回あたりのアップロード上限は315MBとされている。小さな設定ファイルや軽量なSQLダンプなら問題ないが、画像を含むデータや大きなデータベースでは限界に当たりやすい。
この上限を知らずにWranglerだけで運用を始めると、データベースが成長した時点で突然バックアップが失敗する。個人開発サービスのデータ量は、最初は数MBでも、ユーザー投稿やログの蓄積で変わる。最初に動いたことと、半年後も動くことは別の話だ。
大きなファイルを扱うなら、AWS CLIやrcloneを使うほうがよい。S3互換APIに対応しているため、R2専用の書き方に閉じる必要がない。アップロード処理をスクリプトとして分離しておけば、保存先を別のS3互換ストレージへ変更する場合も修正範囲を抑えられる。
アップロード成功を確認する
aws s3 cpの終了コードだけでなく、必要に応じてオブジェクトの存在も確認する。
aws s3 cp \
"${FINAL_FILE}" \
"s3://${R2_BUCKET}/mysql/$(basename "${FINAL_FILE}")" \
--endpoint-url "${R2_ENDPOINT}"
aws s3api head-object \
--bucket "${R2_BUCKET}" \
--key "mysql/$(basename "${FINAL_FILE}")" \
--endpoint-url "${R2_ENDPOINT}"ここまでやって初めて、転送処理を成功と扱える。もちろん、オブジェクトが存在することと、内容が正しいことは同じではない。ファイルサイズの確認やチェックサムの記録を追加する余地もある。
厳密な要件があるサービスでは、暗号化、キー管理、バックアップの改ざん検知、監査ログまで検討する必要がある。しかし、個人開発の初期段階でそこまで一気に作ろうとすると、今度はバックアップの実装が終わらない。まずは毎日自動で取得し、失敗を検知し、復元手順を確認する。そこから必要な強度へ上げていくのが現実的だ。
Cloudflare D1では定期処理の置き場所が変わる
Cloudflare D1を使っている場合、MySQLやPostgreSQLのようにサーバーへSSH接続してmysqldumpやpg_dumpを実行する構成にはならない。Cloudflare Workersの実行環境や、Cron Triggers、Cloudflare Workflowsなどを組み合わせることになる。
D1のデータをSQLとして退避し、R2へ保存する処理は、Cloudflare側のサービスだけで完結させやすい。定期的なトリガーでバックアップ処理を開始し、R2へオブジェクトを作成する流れだ。
構成の考え方は次のようになる。
1. Cron Triggersで定刻にWorkerを起動する
2. D1から必要なデータを読み出す
3. SQL形式など、復元可能な形式へ変換する
4. R2へ日時付きのオブジェクトとして保存する
5. 古い世代を削除する、またはR2のライフサイクルへ任せる
短時間で終わる処理ならCron Triggersでも対応しやすいが、データ量が増えたり、複数段階の処理が必要になったりすると、Cloudflare Workflowsのほうが向いている場合がある。処理の途中で再試行したい、一定時間ごとに分割したい、失敗箇所から再開したい。こうした要件は、単純なcron関数だけで扱うと条件分岐が増える。
D1からすべてのデータを読み出して一つの巨大なSQLファイルにする方法は分かりやすい。一方で、データ量が増えた場合には実行時間やメモリ、1回のアップロードサイズが問題になる。315MBというWrangler経由の制限もあるため、大きなバックアップを一つの処理へ押し込む設計は早めに見直したほうがいい。
D1バックアップで割り切る範囲
D1のバックアップ処理では、次の点を最初に決めておく。
- どのテーブルを対象にするか
- スキーマ変更をどのように記録するか
- データを一括で取得するか、分割するか
- 復元先をD1だけにするか、別のSQLite環境も想定するか
- バックアップ処理が途中で失敗した場合の再試行方法
特に、アプリケーションのマイグレーションファイルとデータバックアップを混同しないことが大切だ。LaravelのマイグレーションやD1のスキーマ定義は、コードとして管理する。ユーザーデータや運用中に追加されたレコードは、バックアップとして退避する。
コードに残るものと、データとしてしか残らないものを分ける。ここを一緒に考えると、復旧時に何を先に適用すればいいのか分からなくなる。
オブジェクトライフサイクルで世代管理を自動化する
バックアップは増え続ける。毎日一つのファイルを保存するだけでも、1年で相当数になる。手動で古いファイルを削除する運用は、忘れる。これは意志の強さではなく、仕組みの問題だ。
R2にはObject Lifecycle機能があり、指定した日数が経過したオブジェクトを自動削除できる。バックアップ用のプレフィックスを分けておけば、MySQL、PostgreSQL、D1などを個別に管理しやすい。
たとえば、次のような保存構造にする。
mysql/:MySQLのダンプpostgres/:PostgreSQLのダンプd1/:D1のバックアップmanual/:リリース前などに手動取得した世代
通常の自動バックアップを一定日数で削除し、手動バックアップは長く残す、といった運用もできる。削除期間はサービスの性質で決める。個人ブログなら数日から数週間で足りるかもしれないが、決済や契約情報を扱うサービスでは、単純に短期保存へ寄せられない。
世代管理を考えるとき、毎日フルバックアップだけにするか、日次・週次・月次で残すかも選択になる。
小規模サービスで扱いやすい世代設計
| 世代 | 保存方法 | 用途 |
|---|---|---|
| 日次 | 毎日取得し、短期間で削除 | 直近の障害や誤操作から戻す |
| 週次 | 週単位のバックアップを残す | 数日前に発生した不具合を調べる |
| 月次 | リリース前や月初に手動取得 | 長期的な復旧ポイントとして残す |
毎日取得したファイルをすべて長期保存すると、無料枠から外れやすい。逆に、直近の1ファイルしか残さないと、壊れたデータをバックアップしていた場合に戻れない。
個人開発でよくある落としどころは、日次の短期保存と、節目の手動保存を組み合わせる方法だ。大掛かりなバックアップ製品を導入しなくても、R2のライフサイクルと命名規則だけで、基本的な世代管理は構成できる。
ただし、ライフサイクルの削除は不可逆だ。保存期間を短くしすぎると、必要な世代まで消える。設定後は一度、テスト用のプレフィックスで挙動を確認したほうがいい。管理画面の項目名を眺めて「たぶんこう動く」と判断するのは、バックアップの世界では少し危険である。
復元テストをしないバックアップは、まだ半分しか完成していない
R2へファイルが保存され、cronも毎日成功している。ここまで作ると、バックアップが完成した気分になる。しかし、本当に必要なのは復元だ。
最低限、月に一度、あるいは構成を変更したタイミングで、次の確認を行う。
1. R2からバックアップファイルを取得する
2. gzipやダンプ形式を正しく展開する
3. 本番とは別の一時データベースを用意する
4. ダンプを復元する
5. 主要なテーブルと件数を確認する
6. アプリケーションから接続し、実際にデータを読めるか確認する
本番DBへ直接上書きする必要はない。Dockerで一時的なMySQLやPostgreSQLを起動し、そこへ復元するだけでも、コマンドの間違い、権限不足、文字コード、拡張機能の不足などを見つけられる。
復元テストで特に確認したいのは、次のような点だ。
- DBユーザーやロールが復元されているか
- 拡張機能や照合順序が環境に合っているか
- トリガーやイベントが抜けていないか
- 文字化けが起きていないか
- Laravelのマイグレーション状態とデータが矛盾していないか
- WordPressのサイトURLやシリアライズデータが壊れていないか
WordPressでは、データベースだけ戻しても、画像などのwp-content/uploadsがなければ完全復旧にならない。Laravelでも、ユーザーがアップロードしたファイルをローカルディスクへ置いているなら、DBバックアップとは別にファイルのバックアップが必要だ。
ここはR2の使い方を考える前に、サービスのデータを分類したほうがいい。
- コード:Gitリポジトリ
- DBのレコード:DBダンプ
- アップロードファイル:オブジェクトストレージなど
- 環境変数:安全な保管場所
- インフラ設定:Docker ComposeやIaCの定義
DBだけを守って安心するのは、鍵だけ持って家財を置き去りにするようなものだ。とはいえ、最初から完璧な災害復旧基盤を作る必要はない。まずは「DBを失ったとき、どこまで戻せればサービスを再開できるか」を決める。その復旧目標に合わせて、必要なバックアップを足していけばいい。
料金をゼロに近づけるための現実的な設計
R2を使えば、個人開発のDBバックアップ費用を無料枠内へ収めやすい。ただし、費用はR2だけで決まらない。
ダンプ生成時のディスク使用量、バックアップ処理を実行するサーバー、ログ保存、監視サービス、通知サービスなどにもコストがある。R2のストレージが無料でも、バックアップのために高価なサーバーを常時稼働させているなら、全体としては節約になっていない。
費用を抑えるなら、次のような順番で整理するとよい。
- フルダンプの取得頻度をサービスの更新頻度に合わせる
- 古い世代を自動削除する
- 画像やログをDBへ入れず、用途に合ったストレージへ分離する
- ダンプをgzipなどで圧縮する
- バックアップ用のAPIキーに最小限の権限を付ける
- 定期的に保存容量と操作回数を確認する
- 復元テスト用の一時環境を必要なときだけ起動する
データがまだ小さい個人アプリなら、毎日フルダンプで十分なことが多い。データ量が増えたら、取得頻度を下げるのではなく、差分バックアップやログベースの方式を検討する。LitestreamのようにSQLiteの変更を継続的に退避するツールもあり、データベースの種類によっては、フルダンプより適した方法になる。
ただし、方式を複雑にするほど復元手順も難しくなる。毎日フルダンプを取って復元できる構成と、細かな差分を積み上げる構成では、障害時の作業量が違う。小規模サービスなら、多少の転送量や処理時間を許容して、復元の分かりやすさを優先したほうがいい場合もある。
結局のところ、最安の構成が最良とは限らない。月数ドルを惜しんで復旧手順が複雑になり、障害時に戻せなければ意味がない。R2の無料枠は、複雑な仕組みを作るための免罪符ではなく、シンプルな運用を安く維持するための余白として使うのがよい。
最初に作るなら、この構成で十分
個人開発のサービスで、まず動かす構成をまとめると次のようになる。
- MySQLなら
mysqldump、PostgreSQLならpg_dumpでダンプを作る - ダンプは日時付きファイル名にする
- gzipなどで圧縮する
- 一時ファイルへ出力し、成功後に正式名へ変更する
- AWS CLIでR2へアップロードする
- cronで毎日実行する
- 失敗時のログと通知を用意する
- R2のライフサイクルで古い世代を削除する
- 定期的に別のDBへ復元する
- APIキーは専用に作り、権限を絞る
Wranglerは、Cloudflare WorkersやD1と同じ環境で処理を組む場合に使う。小さなファイルなら便利だが、315MBのアップロード上限があるため、大きなダンプを前提にするならAWS CLIやrcloneを選ぶ。
ここまでなら、派手な構成ではない。バックアップ専用の管理画面もないし、AIが障害を予測して勝手に復旧してくれるわけでもない。しかし、個人開発の現場では、このくらい単純なほうが長く残る。
コードで解決する部分は、ダンプ生成、転送、エラー停止、世代管理の自動化だ。一方、保存期間の判断、復元の確認、サービスをどこまで戻せればよいかという線引きは、運用で決めるしかない。
Cloudflare R2は、その境界のうち「別の場所へ安く保存する」という部分をかなりきれいに担当してくれる。だからこそ、R2へファイルを送っただけで満足しないことだ。自動化の本体は、ストレージではなく、失敗しても気づけることと、必要な日に戻せることにある。
個人開発のDBバックアップをCloudflare R2で組むなら、まずは毎日一世代を確実に保存し、古い世代を自動で整理する。そのうえで、月に一度だけ復元してみる。理想的なバックアップ基盤を作るより、炎上する前に戻せる構成を持つほうが、結局のところ強い。