ABEJA Tech Blog

中の人の興味のある情報を発信していきます

Firebase Realtime Databaseの利用料増加問題をFirestore移行で解決した話 (年間数百万削減)

はじめに

ある日、このようなIssueが起票されました。

背景を話すと、Insight for Retail の顧客管理システムは5年ほど前にFirebaseで作成されております。 事業を切り開いていった先人たちには足を向けて寝られないのですが、サービスの成長に伴いマイクロサービス化していった結果、この顧客管理システムのRealtime Databaseが足を引っ張っていて、その利用料金が月額40万円年間になおすと480万円ほどになってしまいいました、、、 初期フェーズは問題なかったものの、事業成長とともに無視できない金額になっていました。

Insight for Retailのエンジニアの高木です。 Firebase Realtime DatabaseからFirestoreに一部移行して、最終的にはコストを1/10まで圧縮して年間利用料を400万円削減した事例のご紹介になります。

利用量を計測する

チューニングには何はともあれ計測が大事です。闇雲に変更すると事件が迷宮入りして大量の時間のみを浪費してしまいます。 GCPのConsoleでどのSKUで課金されているか確認してみると、Realtime DBの課金ポイントは100%ダウンロード料金でした。

Realtime DBのダウンロード料金は$1/GBです。 これはかなり割高で、通常GCP内のネットワーク料金はこれに比べるとほぼかからないし、インターネットに出る場合でも最大料金は $0.23/GBです。単純にGCPネットワーク内にDBを移転すればコストが 1/4になりそうでした。

cloud.google.com

次に、データを全部移行するのは影響範囲が大きすぎるので、効果ありそうなデータから順次リプレースできないか検討しました。こちらもどのデータが一番ダウンロードされてるか測定しました。

以下のコマンドで実行中のプロファイルを取得することができることがわかったので、1時間ほど流してどのデータが頻繁にダウンロードされているかを確認してみました。

firebase database:profile
2022/1/14 16:47 ~ 17:17

Downloaded Bytes
┌───────────────────────────┬───────────┬───────┬────────────
│ Path                      │ Total     │ Count │ Average
├───────────────────────────┼───────────┼───────┼────────────
│ /production_1/customers   │ 4.71 GB   │ 7,136 │ 659.79 kB
├───────────────────────────┼───────────┼───────┼────────────
│ /production_1/shops       │ 33.20 MB  │ 7,279 │ 4.56 kB
├───────────────────────────┼───────────┼───────┼────────────
│ /production_1/devices     │ 6.23 MB   │ 3     │ 2.08 MB
...

ほとんどが customers データのダウンロードで占めていることがわかりました。 これは一般的なWebアプリでいう User リソースにあたるデータでして、長年の追加改修で、例に漏れず超大作のデータ構造になっています。

以下の記事にもあるように、Insight for Retailはマイクロサービス化しており、各マイクロサービスからcustomerのデータを取得しにきているため、このような事態が発生しているようでした。

tech-blog.abeja.asia

プランニング

下りネットワークが高すぎることcustomerデータが頻繁にダウンロードされていることが料金がヤバくなる原因ということが判明したので、この課題を解決するプランニングを考えました。

customerデータをリードレプリカ的に移行して、ダウンロード先をそちらに切り替えることで、まずは早急な料金の引き下げを狙いました。 その後段階的に、移行先のデータベースに順次移行していく作戦で進めることにしました。

移行先のDBについてはSQLベースのものとFirestore(Realtime DBの後継プロダクト)の2つ選択肢がありました。

SQLベースのもの(PostgreSQLなど)に移行する

メリット

  • データ分析などを行っている、BigQueryへのコピーが楽
  • エンジニアだけではなくカスタマーサポートやマーケターもSQLがつかえるのでRealtime Databaseのままよりも楽

デメリット

  • いままでスキーマレスなRealtime Databaseを使っていたので、分析してテーブル定義を起こし直すのが困難そう(特に問題となっているcustomerがかなり難しそう)
  • Database Triggerをつかって、データの作成や更新にフックして実行する処理を置き換える必要がある

Firestoreへ移行

メリット

  • firebaseの認証情報をそのまま活かせるので、認証周りなどを移行する作業を省略できる
  • Realtime Databaseと同等のDatabase Triggerが動作する
  • Realtime Databaseと同じようにスキーマレス
  • データ段階的に移行できる(後述)

デメリット

  • BigQueryへのコピーは前処理が必要

個人的にはInsight for RetailはBtoB SaaSのビジネスモデルなので、エンプラっぽくSQL使いたかったのですが、過去リプレースをプランニングしたときにテーブル定義がどうしてもやりきれなさそうなレベルで複雑だったので諦めて、今回は素直にFirestoreへの順次移行を採用することにしました。

データ移行部分

以下のように、CustomerデータをDatabase TriggerRealtime DatabaseからFirestoreにコピる実装をして。 Firestore側をリードレプリカのような形にしました。

Database TriggerとはFirebaseの機能で、データが新規作成されたり、更新されたりしたタイミングでCloud Functionを動作させることができます。

こうすることで書き込み部分はそのままに、まずは読み出し部分を順次Firestoreへ移行しました。

export const changedCustomerInfo = functions.database.ref(`/customers/{customerKey}`)
  .onWrite(copyCustomer)

const copyCustomer = function (change, context) {
  const db = admin.firestore()
  const data = change.after.toJSON()
  db.collection("customers").doc(context.params.customerKey).set(data)
}

データ読み出し部分

データ読み出し部分は以下の感じで書き換えました。 形が決まれば機械的に置換していける内容です。

変更前

// 補足: nodeIdは主キーに当たるデータ構造
export const getCompanyByNodeId = async (nodeId: string): Promise<any> => {
  const ref = admin.database().child('customers').orderByChild('node_id').equalTo(nodeId)
  return getData(ref)
}

const getCustomerByKey = async (customerKey: string): Object => {
  const customerInfo = await admin.database().child(`customers/${customerKey}`).once('value')
  return customerInfo.val()
}

変更後

// 補足: nodeIdは主キーに当たるデータ構造
const getCustomerByNodeId = async (nodeId: string): Object => {
  const snapshot = await admin.firestore().collection('customers').where('node_id', '==', nodeId).get()
  const customer = snapshot.docs[0]

  // realtime dbのときと同じ形式のObjectを返却する
  return {[customer.id]: customer.data()}
}

const getCustomerByKey = async (customerKey: string): Object => {
  const snapshot = await admin.firestore().collection('customers').doc(customerKey).get()
  return snapshot.data()
}

効果検証

一日平均1.5万円ほど発生していた料金を1/101500円ほどに削減することができました。 年間に直すと、為替変動などのバッファを差し引いても400万円ほどのコスト削減に成功する事ができました。

少なくとも 1/4 まで削減と見積もっていましたが、予想通りマイクロサービス間のデータのやり取りが大半だったので、GCP内ネットワークの料金が適応されることとなり、予想よりもより大きな削減効果を得ることができました。

事前のQAとリリース後障害について

ポイントを絞ったリリースだったので、事前のQAも短い時間で十分に行うことができ、大きな影響なくリリースを終えることができました。

さいごに

チューニング系は考えなしにやると頓挫してしまうことが多々あるように思います。 計測すること、経験やカンに頼らず、データをもとに一番効果がありそうな施策から実施すること。 これはInsight for Retailを導入することで顧客が得られる価値なのですが、私自身もプロダクトから学び、効果的に本件を進めることができました。。 大層なこと言ってますが、、ともあれコスト削減に成功してホッとしてます。

告知

株式会社ABEJAでは共に働く仲間を募集しています。興味ある方はぜひこちらの採用ページからエントリーください。

hrmos.co