🔥

Firestore → Cloud SpannerでDBコスト93%削減!無停止でやり切った 1 年間の全記録

に公開
4

カウシェでは2024年5月から2025年5月にかけて、本番環境で運用中のFirestoreからCloud Spannerへの完全移行を実施しました。40以上のコレクション、数億件のデータを扱う本格的なeコマースアプリケーションにおいて、サービスを一切停止することなく移行を完了させた実体験をお話しします。
前回のikeの投稿した記事のこの部分にフォーカスしています。

この移行プロジェクトでは作業工程の約80%をShibataさんが担当し、残りの10%をチームメンバーによる手作業、最後の10%をLLMを活用して効率化しました。移行によりDB費用を大幅に削減でき、パフォーマンス面でも大きな改善を実現できました。

なぜFirestoreからCloud Spannerに移行したのか

カウシェはソーシャルECアプリとして、ユーザー、商品、グループ、購入履歴など複雑なデータ構造を持っています。

当初Firestoreを選択した背景

2020年のサービス開始時、データベース選択においてFirestoreを採用しました。

  • Spannerのコスト問題:当時は100PU単位でのインスタンス作成ができず、スタートアップには高コスト
  • Firestoreの魅力:従量課金で安くスタートでき、初期投資を抑えられた

サービス成長とともに顕在化したFirestoreの課題

しかし、サービスの成長とともに課題が顕在化してきました。
特にインフラコストの増大が「DB移行をやる!」の決定的な理由になりました。

  • 複雑なクエリの制限(複合インデックスの管理コスト)
    • 抽出したデータを、アプリケーション側でソートするなんてこともあった
  • 読み取り・書き込みコストの増大
    • インフラ予算的に許容できないレベルになってきたので、課題感はこれが一番大きかった
  • 分析クエリのパフォーマンス不足
    • firestore -> BQにCloud Functionを使ってデータを転送していました
  • トランザクション処理の制約
    • 270秒のトランザクション時間制限(60秒のアイドル期限)
    • 単一ドキュメントあたり500フィールド変換制限 (トランザクションの500ドキュメント制限は2023年に緩和されていた)
  • 書き込みの上限
    • リクエストサイズ10MiB制限

(コメントで教えていただいた点を補足: トランザクションに関する制約)

Cloud Spannerでは課題が解決

一方でCloud Spannerでは成長に伴う課題が解決されます。

  • 複雑なクエリの実行
    • SQLによる複雑な結合や集計処理が高速に実行できる
  • コスト構造の予測可能性
    • ノード時間課金により従量制のコスト爆発を回避
  • 分析クエリのパフォーマンス
    • BigQueryとの高速連携により分析処理も効率化
  • トランザクション処理の大容量化(80,000件制限)
  • mutationの大幅拡張:80,000件/トランザクション、10,000件/コミット

特にカウシェのような複雑なビジネスロジックを持つアプリケーションでは、SQLの表現力がFirestoreの制約を大きく上回るメリットがありました。

移行戦略の基本設計

クリーンアーキテクチャを活用した段階的移行

カウシェのコードベースではすでにクリーンアーキテクチャを採用していたため、移行作業は主にRepositoryレイヤーの変更に集約できました。

// db/spanner/user_repository.go
type userRepository struct {
    usecase.UserRepository  // Firestore repositoryを埋め込み
    client *libspanner.Client
}

func NewUserRepository(
    firestoreRepo usecase.UserRepository,
    client *libspanner.Client,
) (usecase.UserRepository, error) {
    return &userRepository{
        UserRepository: firestoreRepo,  // 段階的移行のためFirestoreを保持
        client:         client,
    }, nil
}

この設計により、SpannerリポジトリがFirestoreリポジトリを内包し、メソッド単位で段階的に移行できる仕組みを構築しました。

4段階の移行プロセス

各コレクションの移行は4段階の手順で実施しました。

1. Double Write(両DB書き込み)

リポジトリの初期化でSpannerリポジトリがFirestoreリポジトリをラップする構造を作ります。

// db/repositories.go
var (
    // まずFirestoreリポジトリを作成
    coinRepo = firestore.NewCoinRepository(firestoreClient)
    productRepo = firestore.NewProductRepository(firestoreClient)
    userRepo = firestore.NewUserRepository(firestoreClient)
)

// SpannerリポジトリでFirestoreリポジトリをラップ
productRepo = spanner.NewProductRepository(productRepo, spannerClient)
coinRepo = spanner.NewCoinRepository(coinRepo, spannerClient)
userRepo = spanner.NewUserRepository(userRepo, spannerClient)

実際の書き込み処理では、SpannerリポジトリがFirestoreリポジトリのメソッドを呼び出してから、自身のSpanner書き込みを実行します。

// db/spanner/user_repository.go(概念的な実装例)
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
    // まずFirestoreに書き込み
    if err := r.UserRepository.PutUser(ctx, user); err != nil {
        return fmt.Errorf("Firestore write failed: %w", err)
    }
    
    // Firestoreが成功したらSpannerに書き込み
    return r.putUserToSpanner(ctx, user)
}

2. データ移行
バッチ処理で既存データをFirestoreからSpannerに移行しました。

// scripts/migrations/scripts/fire2spanner/user.go
const chunkSize = 1000
for i := 0; ; i++ {
    users, err := firestoreRepo.GetMultiUsersByCreateTime(ctx, baseTime, chunkSize)
    if err != nil {
        return fmt.Errorf("GetMultiUsersByCreateTime failed: %w", err)
    }
    
    time.Sleep(500 * time.Millisecond) // レート制限
    
    if !dryRun {
        if err = spannerRepo.InsertOrUpdateRawUsers(ctx, users); err != nil {
            return fmt.Errorf("InsertOrUpdateRawUsers failed %w", err)
        }
    }
}

3. Read切り替え
Spannerからの読み取りに切り替え、書き込みは引き続き両方に実行しました。

// 移行前: 埋め込まれたFirestoreリポジトリに委譲
func (r *userRepository) GetUsersByCreateTime(
    ctx context.Context, 
    baseTime time.Time,
    limit int,
) ([]*model.User, error) {
    // Spannerインテグレーション用
    // 完全にSpannerへ移行する際に削除する
    if r.UserRepository != nil {
        return r.UserRepository.GetUsersByCreateTime(ctx, baseTime, limit)
    }
    // Spannerの実装(まだ実行されない)
    // ...
}

// 移行後: 条件分岐を削除してSpannerから読み取り
func (r *userRepository) GetUsersByCreateTime(
    ctx context.Context, 
    baseTime time.Time,
    limit int,
) ([]*model.User, error) {
    // Firestore分岐を削除
    // Spannerから直接読み取り
    users := make([]*model.User, 0)
    // Spanner実装...
}

この段階では、Cloud Runのrevision機能を活用し、問題が発生した際は即座に前のrevisionに切り戻すことで安全にロールバックできる体制を整えていました。
また、瞬間的に古いCloud Runインスタンスへのトラフィックが残っていた場合のことも考慮し、両方に書き込んでいました。

4. Write切り替え
Spannerのみへの書き込みに変更しました。

// 移行前: 両方に書き込み
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
    // Spannerインテグレーション用
    // 完全にSpannerへ移行する際に削除する
    // Rolling Update中を考慮して、Firestoreへも保存する
    if r.UserRepository != nil {
        err := r.UserRepository.PutUser(ctx, user)
        if err != nil {
            return fmt.Errorf("PutUser failed: %w", err)
        }
    }
    // Spannerに書き込み
    return insertOrUpdateRow(ctx, r.client, user, toYoUser)
}

// 移行後: Spannerのみに書き込み
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
    // Firestore書き込み部分を削除
    // Spannerのみに書き込み
    return insertOrUpdateRow(ctx, r.client, user, toYoUser)
}

移行の優先順位付けとリスク管理

移行コストとリスクを考慮した段階的アプローチ

移行対象コレクションの選択では、技術的リスクをベースとしつつ、移行難易度と実際のFirestoreアクセス量を総合的に判断しました。

基本方針は「移行が比較的簡単で、Spannerに移行することで大きくインフラ費用が下がるコレクション」を優先することでした。

  1. 移行難易度の評価

    • データ構造の複雑さ(ネストの深さ、関連性)
    • 参照箇所の多さ(影響範囲の大きさ)
    • ビジネスロジックの複雑さ
  2. Firestoreでのアクセス量評価

    • 読み書き頻度と従量課金への影響
    • データ量の増加トレンド
    • Key Visualizerでの実際のアクセスパターン
  3. 移行による費用削減効果

    • Firestoreの読み書き課金削減額
    • Spannerノード時間課金との比較
    • 二重書き込み期間中のコスト増加

例えば、比較的影響範囲の限定的なコレクションでアクセス頻度が高く、Firestoreコストへの影響が大きいものを初期の移行対象として選択しました。逆に、ユーザーデータや購入履歴は移行による効果は大きいものの、ビジネスクリティカルで影響範囲が広いため、ノウハウが蓄積された後期に移行しました。

Key Visualizerによる監視

各段階でFirestore Key Visualizerを活用し、移行対象コレクションへのアクセスがゼロになったことを確認してから次の段階に進みました。これにより、確実にトラフィックが移行されていることを可視化できました。
以下の画像の右の方のように、アクセスがなくなると真っ黒になります。

トランザクション処理と冪等性の確保

自動リトライに対応した冪等設計

FirestoreとSpannerではclient libraryがtransactionのAbortや競合を検知すると 自動でリトライします。

そのため、ビジネスロジックは必ず冪等に設計しておく必要があります。

分割トランザクションへの対応

処理の都合上、トランザクションを2回に分けてコミットする必要がある箇所では、特別な制御フローを実装しました。1回目のFirestoreトランザクションコミット時はSpannerへのコミットをスキップし、2回目のFirestoreトランザクションが成功してからSpannerに書き込みを行う仕組みを構築しました。

IDハッシュ化による冪等性確保

ビジネスロジック側でも冪等性を保つため、処理に使用するIDをハッシュ化して一意性を担保しました。

// リクエストデータからハッシュIDを生成して冪等性を保証
func generateIdempotentUserID(userID string, requestID string) string {
    data := fmt.Sprintf("%s:%s", userID, requestID)
    hash := sha256.Sum256([]byte(data))
    return hex.EncodeToString(hash[:])
}

// 冪等性を保つためのID生成
user.ID = generateIdempotentUserID(userID, requestID)
if err := u.userRepo.PutUser(ctx, user); err != nil {
    // 同じIDで再実行されても安全
    return fmt.Errorf("failed to put user: %w", err)
}

E2Eテストによる安全性確保

データベース非依存のテスト設計

移行において最も重要だったのがE2Eテストの存在でした。外部APIの入出力のみをテストすることで、データベース実装に依存しない検証が可能でした。

// e2etests/create_user_profile_test.go
func TestCreateUserProfile_OK(t *testing.T) {
    res := customers.CreateUserProfile(t, client, ctx, &v1.CreateUserProfileRequest{
        UserProfileIconId:   "1",
        UserProfileNickName: "Bob(自動テストユーザー)",
    })
    
    // APIレスポンスを検証(DB実装は無関係)
    if res.Result.Code != v1.CreateUserProfileResponse_Result_CODE_OK {
        t.Errorf("res.Result.Code is %v, want CODE_OK", res.Result.Code)
    }
    // 以下、レスポンスの各項目を検証 
    ...
}

E2Eテストにおけるリポジトリ抽象化

E2Eテストでは基本的にAPIを呼び出してテストリソースを作成する方針にしていました。これにより、データベース実装の変更がテストに影響しない設計を実現できました。

// e2etests/create_user_profile_test.go
func TestCreateUserProfile_OK(t *testing.T) {
    res := customers.CreateUserProfile(t, client, ctx, &v1.CreateUserProfileRequest{
        UserProfileIconId:   "1",
        UserProfileNickName: "Bob(自動テストユーザー)",
    })
    
    // APIレスポンスのみを検証(DB実装は無関係)
    if res.Result.Code != v1.CreateUserProfileResponse_Result_CODE_OK {
        t.Errorf("res.Result.Code is %v, want CODE_OK", res.Result.Code)
    }
}

この方針により、FirestoreからSpannerへの移行中もテストを書き換える必要がありませんでした。

移行プロセスの型化とLLM活用

知見の蓄積と作業の標準化

複数のコレクションで移行を繰り返すうちに、前述の4段階プロセス(Double Write → データ移行 → Read切り替え → Write切り替え)が確立されました。移行パターンが型化されてくると、実装すべきコードも決まったパターンに収束していきました。

結果として、知見が蓄積された後期の移行作業では、LLMに移行パターンを学習させることで、新しいコレクションの移行コードを自動生成できるレベルまで標準化が進みました。それまでのコレクション移行を通じて培った移行ノウハウが、最終的には再現可能な手法として確立できたのです。

技術的な注意点と学び

Spannerインデックス設計の重要性

Spannerではホットスポットを避けるインデックス設計が重要でした。コードレビューでは特にインデックス設計を重点的にチェックしました。
実際のデータ量は分かっているので、データ移行をする前にしっかりと決め切る必要がありました。

(コメントで教えていただいた点を追記)
Firestoreでもホットスポットは避けるべきでした。
https://firebase.google.com/docs/firestore/best-practices?hl=ja#hotspots

ドキュメント構造の浅さが幸い

カウシェでは幸い、Firestoreのドキュメント階層が浅く、RDBのテーブルにマッピングしやすい構造でした。これにより、NoSQLからRDBへの移行が比較的スムーズに進みました。

Cloud Runによる安全なデプロイメント

サーバーがCloud Runで動作していたため、Read切り替え時に問題が発生した場合は、即座にrevisionを切り替えてロールバックできる体制が整っていました。これにより、移行リスクを大幅に軽減できました。

コスト削減とパフォーマンス向上

移行により以下の改善を実現しました。

  • DB費用の大幅削減:Firestoreを利用したいた時と比較して93%(!)減りました
  • 複雑なクエリのパフォーマンス向上:SQLによる効率的な結合・集計処理
  • 分析クエリの実行時間短縮:BigQueryとの連携による高速分析
  • 開発効率の向上:SQLの表現力による実装の簡素化

まとめ

1年間にわたるFirestoreからCloud Spannerへの移行プロジェクトを通じて、大規模プロダクション環境での無停止データベース移行の実現可能性を証明できました。

成功のポイントは以下の通りです。

  1. クリーンアーキテクチャの活用:リポジトリパターンにより影響範囲を限定
  2. 段階的移行:4段階のプロセスでリスクを最小化
  3. E2Eテストによる安全網:データベース実装に依存しない検証
  4. 慎重な監視:Key Visualizerによる確実な切り替え確認
  5. Cloud Runの活用:revision切り替えによる即座のロールバック体制

この経験を通じて、適切な設計と慎重な実行により、複雑なプロダクション環境でも安全にデータベース移行を実現できることを学びました。今後同様の移行を検討している方の参考になれば幸いです。

カウシェで働くことに興味を少しでも持ってもらえた方はぜひともカジュアル面談をしましょう!
https://youtrust.jp/recruitment_posts/a82b92474ace4f2e2ecaa2d2ef7a3a68

直近開催するイベントの案内です!
https://kauche.connpass.com/event/360073/
https://kauche.connpass.com/event/358309/
https://kauche.connpass.com/event/359790/

カウシェ Tech Blog

Discussion

apstndbapstndb

「サービス成長とともに顕在化したFirestoreの課題」のセクションの項目ですが、事実としては Firestore トランザクションの500ドキュメント制限って今は緩和されていて、今残っているのはリクエストサイズ10MBと似ているようでちょっと違う制限(1ドキュメントに対する変換の制限)だというのは補足したいかなと思いました。

https://cloud.google.com/firestore/docs/release-notes#March_29_2023

Firestore no longer limits the number of writes that can be passed to a Commit operation or performed in a transaction. Previously, the limit was 500. Limits for request size and the transaction time limit still apply.

https://cloud.google.com/firestore/quotas?hl=en#writes_and_transactions

Maximum API request size 10 MiB
Maximum number of field transformations that can be performed on a single document in a Commit operation or in a transaction 500