kickflow Tech Blog

株式会社kickflowのプロダクト開発本部によるブログ

Supabase Storage のバックアップ基盤を Google Cloud + Pulumi で構築した

Supabase Storage backup to Google Cloud

新規事業本部でバックエンドエンジニアをしている渡辺です。

今回は、新規事業のエンタープライズ向け SaaS で実際に進めていた Supabase Storage のバックアップ整備についてまとめます。ユーザーのアップロードしたファイルをどう守るかはプロダクトの土台部分の話で、地味ではありますが、手を入れてみるとけっこう考えることが多かった領域でした。

はじめに

開発中のエンタープライズ向け SaaS で、バックエンドに Supabase(PostgreSQL / Authentication / Storage)を採用しています。ユーザーがアップロードする画像や PDF 等のファイルは Supabase Storage に保管されていますが、今回これらを Google Cloud へ日次でバックアップする基盤を Pulumi で IaC 化して構築しました。

設計の出発点はスタディストさんのテックブログ「Rclone を用いて Supabase の Storage をバックアップする」です。rclone でスケジュール実行という骨格はほぼそのまま借りつつ、バックアップ先と実行基盤は社内で管理しやすい GCP に寄せ、Cloud Run Jobs + Cloud Scheduler + Pulumi で IaC 化する形に組み替えました。

zenn.dev

なぜ Storage のバックアップを自前で用意する必要があるのか

Supabase のドキュメントにも明記されているとおり、Supabase は Storage に対してマネージドな自動バックアップ機能を提供していません。

  • 公式の Migration Guide でも「Storage オブジェクトはバケットのメタデータとは別に個別で移行する必要がある」と記載されています。
  • さらに S3 Compatibility のドキュメントで 「バージョニングは未サポート」と明記されているため、一度削除されたオブジェクトは復元できません。

supabase.com

supabase.com

そのため、少なくとも次のようなリスクは利用者側で見ておく必要があります。

  • Supabase プロジェクト単位の障害:サービス全体の可用性低下・データ消失
  • 操作ミス・アプリケーションバグによる削除:S3 バージョニングがないため、削除されたら取り戻せない
  • ベンダーロックイン緩和:何らかの理由で移行判断が必要になった場合の保険

全体アーキテクチャ

flowchart LR
    subgraph Supabase["Supabase"]
        SupaStorage[("Storage Buckets")]
    end
    subgraph GCP["Google Cloud"]
        Scheduler["Cloud Scheduler<br/>daily cron"]
        Job["Cloud Run Job<br/>rclone container"]
        AR[("Artifact Registry")]
        SM["Secret Manager"]
        GCSBucket[("GCS Bucket")]
        LogMetric["Log-based Metric"]
        AlertFail["AlertPolicy<br/>Job Failure"]
        AlertMiss["AlertPolicy<br/>Missing Execution"]
        NotifCh["Slack Notification Channel"]
    end
    Slack["Slack Channel"]
    Group["Google Group<br/>(restore operators)"]
    Scheduler -->|OIDC invoke| Job
    Job -.->|pull| AR
    Job -.->|read creds| SM
    Job -->|rclone copy<br/>via S3 API| SupaStorage
    SupaStorage ==> Job ==> GCSBucket
    Job -->|logs| LogMetric
    LogMetric --> AlertFail
    Job -.->|execution count| AlertMiss
    AlertFail --> NotifCh
    AlertMiss --> NotifCh
    NotifCh -->|Bot Token| Slack
    Group -.->|restore access| GCSBucket

データフロー

  1. Cloud Scheduler が日次スケジュールで Cloud Run Job を OIDC 認証で HTTP invoke
  2. Cloud Run Job が Artifact Registry からイメージを pull し、rclone コンテナとして起動
  3. Secret Manager から S3 互換アクセスキーを取得
  4. rclone が Supabase Storage を S3 API 経由で読み取り、GCS に差分コピー
  5. 実行ログは Cloud Logging に流れ、エラー系はログベースメトリクス経由で AlertPolicy が発火
  6. 通知は Slack の通知チャネルへ Bot Token 経由で送信
  7. 災害時のリストア作業者は Google Group で権限管理、Cloud Audit Log で誰がリストア用の認証情報を参照したかを追跡可能

技術選定

rclone を選んだ理由

同記事でも採用されており定番の選択ではありますが、改めて整理すると以下のようなメリットがあります。

  • S3 互換 API に標準対応しており、Supabase Storage が公開している S3 API とそのまま繋がる
  • デフォルトで差分転送(サイズ + 更新時刻でスキップ判定)
  • 並列化、リトライ、--fast-listrclone size による事前計測など、運用に必要な機能が一通り揃っている
  • Docker イメージ(rclone/rclone:alpine)が公式で提供されている

Supabase の公式ドキュメントにも rclone の例は普通に載っていて、プラットフォーム版からセルフホスト版へオブジェクトを移すガイドに rclone.conf の設定サンプルがそのまま出てきます。S3 API を rclone で叩くのは Supabase 側も想定しているユースケースということなので、互換性まわりで引っかかる不安は少なめでした。

supabase.com

なお、大量のオブジェクトを含むバケットを rclone で扱う際は、List API のバージョン差異に起因する既知の問題があります。Supabase 公式のトラブルシューティングでも言及されており、--s3-list-version 2 フラグで回避可能です。今回の構成でも将来的なオブジェクト増加を見越して、このフラグを標準で付与しています。

supabase.com

Cloud Run Jobs を選んだ理由

バッチ基盤の候補としては常駐プロセスを持たずに済み、構成もシンプルになる Cloud Run Jobs を選びました。同記事では AWS の ECS Scheduled Task を使っていましたが、GCP 内で Cloud Scheduler → Cloud Run Job と完結できるため、組むべき構成要素が少なく保守もしやすい、というのが決め手です。

  • 常駐プロセス不要のバッチ実行ワークロード向きで、待機コストがかからない(実行時だけ課金)
  • Cloud Scheduler + OIDC と組み合わせれば cron 駆動が完結する
  • Supabase Storage の全量を扱うにもメモリ上限は十分

Pulumi (TypeScript) を選んだ理由

  • 既存の社内 GCP インフラが Pulumi で管理されていた
  • TypeScript の型チェックが効くため、GCP リソースの設定ミスをコンパイル時に検出可能
  • 関数分割によるリソース定義の再利用性

同等の要件は Terraform でも実装可能ですが、社内の既存スタックと運用を合わせる判断で Pulumi を継続採用しました。

実装のポイント

1. Pulumi によるリソース定義(32 リソース)

Pulumi プロジェクトは infra/supabase-backup/ 以下に配置し、責務ごとにファイルを分割しました。

infra/supabase-backup/
├── docker/                      # rclone コンテナの Dockerfile / 起動スクリプト
└── src/
    ├── apis.ts                  # API 有効化
    ├── storage.ts               # GCS バケット
    ├── registry.ts              # Artifact Registry
    ├── secrets.ts               # Secret Manager
    ├── iam.ts                   # Service Account / IAM バインディング
    ├── job.ts                   # Cloud Run Job
    ├── scheduler.ts             # Cloud Scheduler
    ├── monitoring.ts            # Cloud Monitoring (AlertPolicy + Slack 通知)
    ├── alert-documentation.ts   # アラートに付与する Runbook / 対応手順ドキュメント
    └── index.ts                 # すべてを組み立て

pulumi up 1 コマンドで、API 有効化 → Artifact Registry → GCS → Secret Manager → SA → IAM → Cloud Run Job → Cloud Scheduler → 監視、の順に依存関係を解決して作成されます。

2. rclone.conf の動的生成と入力値のバリデーション

コンテナ起動時に、環境変数から rclone.conf を生成します。

cat > "$RCLONE_CONFIG" <<EOF
[source]
type = s3
provider = Other
endpoint = ${S3_ENDPOINT}
region = ${S3_REGION}
access_key_id = ${S3_ACCESS_KEY_ID}
secret_access_key = ${S3_SECRET_ACCESS_KEY}
force_path_style = true

[destination]
type = google cloud storage
bucket_policy_only = true
EOF

方針として、rclone.conf に挿入する環境変数の値は事前にバリデーションし、意図しない制御文字(改行・ブラケット等)が含まれる値は即座にジョブを失敗させる入力チェックを入れています。

for var in S3_ENDPOINT S3_REGION S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
  value="${!var}"
  case "$value" in
    *$'\n'*|*$'\r'*|*'['*|*']'*)
      echo "[ERROR] '$var' contains forbidden characters" >&2
      exit 1
      ;;
  esac
done

3. Cloud Scheduler → Cloud Run Job の OIDC 認証

Cloud Scheduler から Cloud Run Job を呼ぶには、HTTP invoke + OIDC token の組み合わせが最もシンプルで安全です。Pulumi でこう書きます。

new gcp.cloudscheduler.Job("backup-scheduler", {
  schedule: config.require("schedule"),
  timeZone: config.require("timeZone"),
  httpTarget: {
    uri: cloudRunJobRunUri,
    httpMethod: "POST",
    oidcToken: {
      serviceAccountEmail: schedulerSa.email,
      audience: "https://run.googleapis.com/",
    },
  },
})

backup-scheduler SA に roles/run.invoker を付与するのを忘れないこと。Pulumi で同時に定義してしまうのが安全です。

4. 監視の二層構成

ジョブ失敗検知はログベースメトリクス + AlertPolicy で構成しました。

const errorMetric = new gcp.logging.Metric("backup-job-error-metric", {
  name: metricName,
  filter: [
    'severity>=ERROR',
    'resource.type="cloud_run_job"',
    `resource.labels.job_name="${jobName}"`,
  ].join(" AND "),
  metricDescriptor: { metricKind: "DELTA", valueType: "INT64", unit: "1" },
})

未実行検知(スケジューラ障害等で 1 日分走らなかった場合)は、run.googleapis.com/job/completed_execution_count メトリクスの欠如を条件にします。

conditions: [{
  conditionThreshold: {
    filter: 'metric.type="run.googleapis.com/job/completed_execution_count" AND ...',
    duration: "60s",
    comparison: "COMPARISON_LT",
    thresholdValue: 1,
    aggregations: [{
      alignmentPeriod: "90000s",  // 25h (GCP 上限)
      perSeriesAligner: "ALIGN_SUM",
    }],
    evaluationMissingData: "EVALUATION_MISSING_DATA_ACTIVE",
  },
}]

この 2 つの設定には GCP 特有の落とし穴があり、それは後述します。

5. リストア作業者への最小権限

Supabase Storage にはバージョニングがないため、削除されたオブジェクトの復旧は今回構築したバックアップを使うしかありません。災害時のリストアは限定メンバーのみが実施できるよう、Google Group に対して Secret Manager と GCS 両方に最小権限を付与しました。

new gcp.secretmanager.SecretIamMember("restore-operators-secret-reader", {
  secretId: restoreCredentialSecret.id,
  role: "roles/secretmanager.secretAccessor",
  member: `group:${restoreGroupEmail}`,
})

new gcp.storage.BucketIAMMember("restore-operators-bucket-reader", {
  bucket: backupBucket.name,
  role: "roles/storage.objectViewer",
  member: `group:${restoreGroupEmail}`,
})

Secret Manager 側で Cloud Audit Log の DATA_READ を有効化すれば、「誰が・いつ・リストアキーを参照したか」を後追いできます。

new gcp.projects.IAMAuditConfig("secretmanager-audit-config", {
  project: projectId,
  service: "secretmanager.googleapis.com",
  auditLogConfigs: [{ logType: "DATA_READ" }],
})

実際にデプロイして気づいたこと

ここからは、実装を進める中で実際に詰まった点のメモです。いずれもドキュメントを眺めているだけでは見落としがちで、pulumi up を叩いて初めて気づくようなタイプのものが中心になっています。

落とし穴 1: alignmentPeriod は 25 時間が上限

「直近 26 時間に成功 execution が 0 件ならアラート」として alignmentPeriod: "93600s" (26h) を指定したところ、以下のエラーが出ました。

Field alert_policy.conditions[0].condition_threshold.aggregations[0].alignment_period
had an invalid value of "Alignment periods longer than 25h are not supported."

Cloud Monitoring の alignmentPeriod は最大 25h (90000s)が上限です。公式ドキュメントの目立つ場所には書かれておらず、デプロイしてから気づきました。25h に丸めることで、日次スケジュール + 1h の許容で実用上問題なく動作しています。

落とし穴 2: evaluationMissingData 使用時は duration が必須

completed_execution_count はデルタメトリクスで、execution が 0 件の期間は時系列データ自体が存在しません。そのままでは閾値比較が評価されないため、「データがない = 未実行」と解釈するには evaluationMissingData: "EVALUATION_MISSING_DATA_ACTIVE" を指定します。

しかしここで注意が必要で、同じ condition の duration は非ゼロでなければならないという制約があります。

Conditions setting evaluation_missing_data must have a non-zero duration.

duration: "60s" に変更して解消しました。データ不在が 60 秒継続したらアラート発火、という意味です。

落とし穴 3: Cloud Run Job は Secret の latest version を要求する

Pulumi で Secret Manager の「箱」だけを先に作り、値の投入は後で手動、というフローで進めていたところ、Cloud Run Job 作成で以下のエラーが発生しました。

Secret projects/.../secrets/<secret-name>/versions/latest was not found

Cloud Run Job は作成時に参照先 Secret の latest version が存在するかを検証する仕様です。したがって、手順は以下の 2 段階が必要になります。

  • Pulumi で Secret の「箱」を作成(最初の pulumi up で一度止める)
  • gcloud secrets versions add で値を投入
  • Pulumi で Cloud Run Job を作成(再度 pulumi up

少し歪ですが、この順序を踏まないと詰まります。

実行結果

Preview 環境で実際にバックアップジョブを走らせたところ、Cloud Run Job の起動から終了までは十分に現実的な時間で完了しています。2 回目以降の差分転送では、Supabase 側に変更がなければ Transferred: 0 で即完了するため、日次バッチとしての運用コストも軽微です。

まとめ

Supabase Storage のバックアップを GCP + Pulumi + rclone で構築した事例を紹介しました。

最後に、実際にやってみて重要だった点だけ整理しておきます。

Supabase Storage は便利ですが、マネージドバックアップもバージョニングも提供されていないので、利用者側で「消えたら終わり」のリスクを引き受けることになります。今回のように別クラウドへの定期バックアップを組む必要があります。転送まわりは rclone + Cloud Run Jobs + Cloud Scheduler の組み合わせで十分素直に書け、Pulumi で IaC 化したことで再現性や監査証跡もついでに手に入りました。

一方で、Cloud Monitoring の alignmentPeriod 25h 上限や evaluationMissingDataduration の組み合わせ、Cloud Run Job が作成時に Secret の latest や Artifact Registry の image を検証する挙動など、ドキュメントだけでは気づきにくい落とし穴はいくつかあったので、同じような構成を組む方の参考になれば幸いです。

We are hiring!

kickflowでは、今回ご紹介したようなインフラ整備など、運用保守から機能開発まで幅広く活躍できるエンジニアを募集しています。ぜひ採用サイトをご覧ください。

careers.kickflow.co.jp