セルフホスト型サービスの災害復旧:当社のバックアップ戦略
セルフホスティングでは、マネージドサービスにはない管理の自由が得られます。ハードウェア、ネットワーク、データの保存・処理地域、コスト構造を自分で選べます。ただし、その自由には、マネージドサービスが表立たずに担っている責任が伴います。問題が起きれば、すべて自分で対処しなければなりません。Supabase、CRM、DAM、Gitサーバーなど、複数の本番サービスを1台で動かす構成では、バックアップと復旧の戦略は後回しにできません。復旧可能な障害で済むか、データを永久に失うかを左右します。
本記事では、当社がセルフホスト型の本番環境で運用しているバックアップ戦略を紹介します。対象は、PostgreSQLを主要データストアとするSupabaseスタック、セルフホスト型CRMのTwenty(こちらもPostgreSQLを使用)、GitサーバーのGitea、デジタルアセット管理システムです。すべて1台のHetzner VPS上でDockerコンテナとして稼働しています。許容できない高負荷の時間帯や、複雑な処理間の依存関係を生まずに、すべてのサービスを保護する必要があります。
単一サーバーがリスクを高める理由
単一サーバー構成には、冗長性が標準では備わっていません。待機レプリカも、複数のアベイラビリティーゾーンにまたがるフェイルオーバーも、ヘルスチェックを契機とするデータベースの自動復元もありません。ハードウェア故障、アップグレードの失敗、ストレージの問題、セキュリティインシデントなどでサーバーが停止したとき、復旧手段はバックアップだけです。そのバックアップが不完全、古い、未検証のいずれかであれば、復旧できる範囲も制限されます。
これは、インフラ事業者がフェイルオーバーを処理するため、ハードウェア故障がほぼ表面化しないマネージドクラウドサービスとは異なる脅威モデルです。単一のVPSでは、マネージドサービスが隠蔽している障害の種類を明示的に想定して計画します。計画自体はそれほど複雑ではありませんが、障害発生中ではなく、発生前に用意する必要があります。
バックアップが必要なもの
サービスごとに保存するデータの性質は異なります。代替できないデータは何か、それがどこにあるかを把握することが、バックアップ戦略を設計する前提です。
Supabaseは、Supabaseスタックが管理するPostgreSQLインスタンスにデータを保存します。データベースには、すべてのアプリケーションデータ、ユーザーレコード、Supabase自身の認証・ストレージのメタデータが含まれます。ストレージバケット(アップロードされたファイル)はディスクに保存されるため、データベースとは別にバックアップする必要があります。
Twenty CRMは、Supabaseとは別の専用PostgreSQLインスタンスを使用します。連絡先、商談データ、ワークフローの状態が保存されています。データ量は比較的少ないものの、機密性は高く、CRMデータの喪失は、再作成できるアプリケーションデータの喪失よりも業務に大きな影響を与えます。
Giteaは、リポジトリをベアGitオブジェクトとしてディスクに保存します。履歴を省略していない完全なクローンには取得済みの履歴が含まれるため(ただし、Git LFSオブジェクトや未取得の参照は含まれません)、リポジトリデータは原則として開発者の端末から再構成できます。ただし、課題管理データ、プルリクエストのコメント、チーム設定はGiteaのデータベースにしかなく、クローンには含まれません。GitオブジェクトとGiteaのデータベースの両方をバックアップする必要があります。
DAM(デジタルアセット管理)は、オリジナルファイル、加工済みの派生ファイル、メタデータを保存します。代替できないのはオリジナルファイルで、派生ファイルは再生成できます。メタデータはデータベースにあり、ファイル間の関係、タグ、利用権を記録しています。
pg_dumpによるバックアップの実行管理
PostgreSQLのpg_dumpは、データベースの論理バックアップに適したツールです。データベースをSQL形式で書き出し、互換性のあるPostgreSQLバージョンに復元できます。同じPostgreSQLバージョンとバイナリ構造を必要とする物理バックアップより、移植性に優れています。
各PostgreSQLインスタンスのバックアップスクリプトは、同じパターンに従います。
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
docker exec "${CONTAINER_NAME}" pg_dump -U postgres -d "${DB_NAME}" | gzip -9 > "${OUTPUT_FILE}"
echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"
冒頭のset -euo pipefailが重要です。パイプライン内を含め、いずれかのコマンドが失敗するとスクリプトを直ちに終了させます。pipefailがない場合、pg_dumpが失敗しても後続のgzipが成功すれば、空または途中で切れた圧縮ファイルが残ります(エラーメッセージはファイルではなくstderrに出力されます)。それでもスクリプトは成功を報告するため、問題が明らかになるのは復元時です。
出力は後処理ではなく、その場でgzip -9により圧縮します。これによりディスク使用量を抑え、未圧縮のダンプを先にディスクへ書き込まずに済みます。空き容量を厳密に管理しているサーバーでは、この点が重要です。
開始時刻をずらしたスケジュール
すべてのバックアップジョブを同時に実行すると、I/Oが競合する時間帯が生じ、対象サービスの性能を低下させるおそれがあります。負荷のかかったSupabaseインスタンスが、ディスクI/Oをpg_dumpと奪い合うのは望ましくありません。対策は開始時刻をずらすことです。前のジョブが終わってから次のジョブが始まるよう、十分な間隔を設けます。
当社が使用するスケジュールをcronの設定で示します。
# Supabase PostgreSQL backup — 02:00 daily
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db
# Twenty CRM PostgreSQL backup — 02:30 daily
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db
# Gitea database backup — 03:00 daily
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db
# DAM database backup — 03:30 daily
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db
# Gitea repository objects — 04:00 daily
0 4 * * * /opt/scripts/git-objects-backup.sh
# Storage files (Supabase + DAM originals) — 04:30 daily
30 4 * * * /opt/scripts/files-backup.sh
ジョブ間の30分という間隔は余裕を持たせた設定です。この規模のデータベースなら、ほとんどのバックアップは数分で完了します。ただし、この間隔にはバックアップ後のアップロードも含めています。次のジョブが始まる前に圧縮ファイルをオブジェクトストレージへ転送し、ローカルディスクに数日分のバックアップが同時に蓄積しないようにします。
オブジェクトストレージへのアップロードと保持
保護対象のサービスと同じサーバーにしか存在しないバックアップは、バックアップとして機能しません。元のデータを失う障害で一緒に失われるスナップショットにすぎません。ローカルコピーを作成しただけで完了とせず、すべてのバックアップを別の保存先へアップロードする必要があります。
当社はVPS事業者とは別のS3互換オブジェクトストレージ事業者を利用し、rcloneでアップロードしています。再試行と転送後のチェックサム検証を自動で処理できます。
rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/" --checksum --transfers 1 --log-level INFO
--checksumフラグを指定すると、rcloneは更新日時とサイズではなく、ハッシュとサイズでファイルを比較します(S3互換のリモートではMD5を使用)。rcloneが標準で行う転送後のハッシュ検証と合わせて、リモート側に利用可能なハッシュが保存されている場合に転送中の破損を検出します。
保持期間は、週1回実行する別のクリーンアップジョブで管理します。日次バックアップは7日間、週次バックアップ(日曜日の日次バックアップを週次ジョブで改名)は4週間、月次バックアップ(毎月第1日曜日)は6か月保持します。すぐには気づかないデータ損失を検出するための期間を確保でき、例えば3週間前に破損した行も、週次バックアップから復元できます。
保持期間を過ぎたバックアップの削除には、ファイル名だけに頼らず、更新日時(--min-age)で絞り込むrclone deleteを使います。ファイル名の規則が完全には統一されていない場合でも、こちらの方が確実です。
rclone delete "backup-remote:tva-backups/postgres/" --min-age 7d --include "*_daily_*" --dry-run
クリーンアップジョブのテスト時には--dry-runフラグを使用します。フィルターパターンが削除対象に正確に一致することを確認してから、このフラグを外してください。--includeのパターンは、日次バックアップのファイル名に_daily_という目印が含まれることを前提としています。一方、上記のバックアップスクリプトはDB_TIMESTAMP.sql.gzという形式で命名するため、先にパターンかファイル名を合わせてください。
復元テスト
未検証のバックアップは、復元できるという仮説にすぎません。復元できることを確かめる唯一の方法は、実際に復元することです。当社は、本番スタックから隔離した一時的なDockerコンテナを使い、各データベースを毎月復元テストしています。
#!/usr/bin/env bash
set -euo pipefail
BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"
# Start a temporary PostgreSQL container
docker run -d --name "${TEST_CONTAINER}" -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=testdb postgres:15-alpine
# Remove the container on exit, also when a step fails
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT
# Wait for PostgreSQL to be ready
for _ in $(seq 1 30); do
docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
sleep 1
done
# pg_dump omits roles: restore them first from a pg_dumpall --roles-only file (optional 2nd argument)
if [ -n "${ROLES_FILE}" ]; then
docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi
# Restore the backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -v ON_ERROR_STOP=1
# Verify row counts match expectations
docker exec "${TEST_CONTAINER}" psql -U postgres -d testdb -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
行数の検証は網羅的ではありません。主要テーブルの推定行数が妥当かを確認するもので(n_live_tupは統計に基づく推定値であり、正確な件数ではありません)、すべての行が正しいかまでは確認しません。より徹底したテストでは、復元したデータベースに対してアプリケーション自身のヘルスチェックを実行します。それでも行数の確認で、バックアップの途中切断、復元失敗による空のデータベース、バージョン非互換による気づきにくいデータ損失といった、よくある不具合を検出できます。復元はON_ERROR_STOP=1付きで実行されるため、最初に失敗したSQL文でテストが失敗し、trapがどちらの場合もコンテナを削除します。pg_dumpはロールを含みません。ダンプがpostgres以外のロールを参照している場合は、移行元サーバーのpg_dumpall --roles-onlyの出力を第2引数に渡します(このステップで既存のpostgresロールに対するエラーが出るのは想定どおりです)。テスト用イメージは移行元のPostgreSQLメジャーバージョンに合わせてください。Supabaseのダンプは、通常のpostgresイメージにはないSupabase固有のスキーマや拡張機能も参照するため、Supabase自身の復元手順に従ってSupabaseインスタンスへのテスト復元が必要です。
実際の復旧手順
バックアップ戦略の有用性は、それを使う復旧手順に左右されます。サーバーの構成管理リポジトリにMarkdown文書として保存した復旧手順書には、新しいVPSで各サービスを復元する正確な手順を記載しています。
サーバー全体が停止した場合は、新しいVPSを用意し、構成管理リポジトリからDockerとアプリケーションスタックをインストールします。次にオブジェクトストレージから最新のバックアップをダウンロードし、依存関係に従って各データベースを復元します(Supabase、CRM、その他の順)。その後、ファイルストレージを復元し、DNSを更新して各サービスを検証します。すべてのバックアップが最新で、手順書に従い、その場で手順を考え直さずに進められれば、完全復旧までの所要時間は2時間未満を想定しています。
この見積もりの要点は「その場で手順を考え直さずに進められる」ことです。手順書は、システムに一度も触れたことのない人でも実行できるほど具体的である必要があります。コマンドは文章で説明するだけでなく、コピーして実行できる形で示します。すべての手順に検証項目を設けます。復旧手順書の曖昧さは、実際の障害時のストレスによって影響が増すリスクです。
当社は年1回、ステージング環境への完全復旧を実施して手順書を検証します。このテストでは毎回、Dockerイメージのバージョン変更、設定キーの改名、環境変数の追加など、前回の手順書作成後に変わった手順が少なくとも1つ見つかります。実際の復旧中に気づくより、定期テストで発見する方がはるかに望ましい対応です。
関連記事
- 段階的なセキュリティ監査:Next.jsアプリケーションのセキュリティ強化 – バックアップ戦略を補完するセキュリティ対策で、復旧を必要とするインシデントの発生確率を下げます。
- 数千問を収録した試験対策アプリの構築 – 本番バックエンドとしてのSupabaseと、バックアップ対象に影響するRLS・スキーマ設計の判断を解説します。