Firebase
イベント
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
G-gen の杉村です。Google が提供する Google AI Studio で発行した API キー が何らかの方法で他人に知られたことにより、悪意ある主体によって大量に Gemini モデルへのリクエストが発行され、利用料が過剰に発生する事象が観測されています。当記事ではこの事象の説明と、対処法について解説します。 事象と背景 事象の原因 キーが他人に知られた原因 不正利用の原因 対策 対策の一覧 対象者 予算アラートと異常検知の設定 予算アラート 請求先アカウントの異常検知 迷惑メールに分類されない設定 Spend Caps の使用(Private Preview) 使用状況の把握 把握方法 課金レポートの確認 Cloud Asset Inventory の確認 API キーの制限の徹底 概要 API キーの所在の把握 API キーの制限 ベストプラクティスへの準拠 Google AI Studio から Vertex AI への移行 Vertex AI を第一選択肢に 移行する場合 追加のセキュリティ施策 Google AI Studio の使用禁止 管理者設定による使用禁止 短絡的に禁止しない 目的別のプロジェクト分離 事象と背景 Google が提供する Web サービスである Google AI Studio では、 API キー を発行することで、生成 AI モデル Gemini を API 経由で呼び出すことができます。 Web 上のブログ記事や SNS などの情報では、この Google AI Studio で発行される API キーを使って、AI CLI ツールや IDE、その他 AI 関連ツールから Gemini を呼び出す方法が頻繁に紹介されています。 一方で2026年4月現在、API キーが何らかの方法で他人に知られたことにより、悪意ある第三者によって大量に Gemini モデルへのリクエストが発行され、利用料が過剰に発生する事象が複数件、観測されています。ケースによっては、数百万円を超える課金が発生したとされています。こうした課金は、たとえ意図しないものであっても、原則としてユーザー側が支払う義務を負うことになります。 このような事象が発生しないよう、厳正な予防措置が必要です。また、後述のように、Google AI Studio は「個人開発者、研究者、学生」等を対象としたサービスとされています。企業等が API 経由で Gemini を使用する場合は、 Vertex AI と サービスアカウント の使用が推奨されます。 当記事ではこの事象の説明と、対処法について解説します。 Google AI Studio の画面 事象の原因 キーが他人に知られた原因 API キーが「流出」する原因は、複数が考えられます。 クライアントや Web ページへのハードコーディング(Google Maps や Firebase など、キーがクライアント側に露出することが前提の場合を含む) GitHub 等の公開リポジトリへのアップロード その他、意図しない公開 特に、Google Maps や Firebase など、キーがクライアント側に 露出することが前提 の API キーが「流出」し、Gemini API の不正利用に繋がったケースは注目に値します。これらの API キーは公式に「シークレット(機密情報) ではない 」と案内されているほか、HTML にハードコードする手法が紹介されているなど、クライアント側に露出することが前提であるとされてきました。そのため、このキーが他人に知られることは、厳密にいうと「流出」ではありません。 Google AI Studio における API キーの一覧 不正利用の原因 問題は、これらの API キーは Google Cloud プロジェクトに所属 する API キーであり、 他の API の呼び出しにも共通して使用できる ものであるという点です。 API キーには 制限 (restrictions)を設定できます。API キーの制限とは、アクセス可能な API を限定する設定のことです。クライアントに露出しているキーは、Google Maps API や Firebase API などに制限されているべきです。制限がかかっていないキーを使うと、そのキーが所属するプロジェクトで 有効 (enabled)になっている他の API を呼び出すことができます。 参考 : API キーに制限を追加する | API Keys API Documentation 過剰請求が発生したケースの中には、当初は API キーが所属するプロジェクトで Gemini API が有効化されていなかったため問題なかったものの、 あとから Gemini API が有効化された ことで、制限なしの API キーによって Gemini API を呼び出せるようになってしまった、という経緯のものがあったと考えられます。 Google Cloud における API キーの一覧 対策 対策の一覧 意図しない過剰請求を避けるには、以下のような対策の一部または全部を行うことが望ましいといえます。 予算アラートと異常検知の設定 使用状況の把握 API キーの制限の徹底 Google AI Studio から Vertex AI への移行 Google AI Studio の使用禁止 目的別のプロジェクト分離 上記は、概ね1から順番に行うことが望ましいですが、必ずすべてを実行する必要があるわけではありません。各項目の内容を理解して、必要性を判断してください。特に、1から3までは被害の拡大を防ぐために優先して順に実施することが望ましいです。4から6については、組織のポリシーや開発体制に合わせて適切なものを並行して検討、実施してください。 対象者 上記対策は、主に情報システム担当部門等、組織全体の情報セキュリティを管理する立場の方が実施することを想定したものも含まれていますが、開発者や利用部門などの一般利用者も参考にするべき対策も含まれています。 管理者、開発者、その他の一般利用者のいずれも、上記の対策を理解して検討することが推奨されます。 予算アラートと異常検知の設定 予算アラート 万が一、API キーやその他の認証情報が流出して API が不正利用され、過剰請求が発生した際には、その状況をすぐに検知して対策する必要があります。迅速に検知できるよう、請求先アカウントに 予算アラート を設定してください。 参考 : 予算と予算アラートの作成、編集、削除 | Cloud Billing | Google Cloud Documentation 参考 : 予算アラートの設定方法 - G-gen Tech Blog 予算アラートは、組織レベル、フォルダレベル、プロジェクトレベルのそれぞれで作成できます。それぞれのレベルで必要な権限が異なるため、詳細は上記のドキュメントを参照してください。 予算アラートの設定画面 請求先アカウントの異常検知 請求先アカウントには、予算アラートとは別に、 異常検知 (Anomaly detection)も設定可能です。 異常検知を正しく設定すると、過去の支出状況と比較して、異常と判断された場合に、請求先アカウントに「請求先アカウント管理者」ロールを持っている人や、該当のプロジェクトに「オーナー」ロールを持っている人に対してメール通知等を発報することができます。 参考 : 費用の異常を表示して管理する 参考 : Google Cloud請求先アカウントの異常検知(Anomaly Detection)を解説 - G-gen Tech Blog 迷惑メールに分類されない設定 予算アラートや異常検知のメールが Google から正しく届くよう、以下のドキュメントに掲載されているメールアドレス等からのメールが迷惑メールに分類されたりすることのないよう、正しく設定しておく必要があります。 参考 : Google Cloud サービスに関する重要なお知らせ - MSA チームが使用するメールアドレス Spend Caps の使用(Private Preview) 2026年4月23日、Google Cloud の旗艦イベント「Google Cloud Next」で、 Spend Caps 機能が公開されました。Spend Caps を使うと、Google AI Studio、Gemini Enterprise Agent Platform(旧称 Vertex AI)、Cloud Run、Cloud Run functions において、プロジェクトレベルのコスト制限を設けることができます。設定した予算に達するとアラート、もしくは API トラフィックの一時停止が可能です。 ただし2024年4月現在、当機能は Private Preview であり、使用には申請のうえ Google の審査が入ります。必ず使用できるわけではないうえ、Preview 中のサービスは本番環境での使用が推奨されていません。当機能が一般公開(GA)されるまでは他の対策を充実することを検討してください。 参考 : AI時代に向けた次世代FinOps 使用状況の把握 把握方法 単一または少数のプロジェクトの場合 単一または少数の Google Cloud プロジェクトの範囲内であれば、Google AI Studio と API キーの利用状況の確認は簡単です。 Google AI Studio の API キー一覧画面( https://aistudio.google.com/api-keys )にアクセスすることで、主に自分が発行した API キーの一覧を確認できます。 ただし、ここに一覧表示されるキーは、同画面で「インポート」した Google Cloud プロジェクトに紐づくキーのみです。企業等の組織全体のキー発行状況を確認するには、すべての Google Cloud プロジェクトをインポートする必要があり、これは UI の仕様からも現実的ではありません。 Google AI Studio における API キーの一覧 組織全体の場合 情報システム担当部門等のクラウド管理者が、Google Cloud 組織全体で Google AI Studio や API キーの使用状況を把握するためには、以下のような複数の手法が知られています。 課金レポートの確認 Cloud Asset Inventory の確認 以下に、それぞれの手法の概要と、その手法で何が把握できるのかについて解説します。 課金レポートの確認 自組織の 請求先アカウント の 課金レポート を確認することで、Google AI Studio 経由の Gemini API に関する課金の発生有無を把握できます。 これにより把握できることは「Google AI Studio 経由の Gemini API が使用され、料金が発生しているプロジェクト ID の一覧」です。把握できるスコープは「その請求先アカウントと紐づいているすべてのプロジェクト」です。 この操作を行うには、該当の請求先アカウントに対して少なくとも請求先アカウント閲覧者( roles/billing.viewer )ロールが必要です。権限が不足している場合、後述の手順で請求先アカウントを選択できません。 手順は、以下のとおりです。 Google Cloud コンソール( https://console.cloud.google.com/ )にログイン 検索ボックスに「レポート」と入力 サジェストされた「レポート / プロダクト ページ・課金」をクリック プルダウンメニューから請求先アカウントを選択 「レポートに移動」ボタンをクリック この画面で、以下の操作を行います。 画面上部の「グループ化」フィルタで「プロジェクト」を選択 画面上部の「サービス」フィルタでサービスを「Gemini API」のみに絞る 必要に応じて画面上部の「期間」フィルタで、期間を「使用日」「先月」に設定 これにより画面下部に、Google AI Studio 経由の Gemini API に関する課金が発生しているプロジェクトの一覧や、その課金額が一覧表示されます。 課金レポートによるプロジェクトの特定 Cloud Asset Inventory の確認 組織レベルで Cloud Asset Inventory を確認することで、Gemini API が有効化されているプロジェクト、すなわち Google AI Studio の API キーが発行されている可能性が高いプロジェクトを特定できます。 Cloud Asset Inventory は、組織やプロジェクトのクラウドリソース(アセット)のメタデータを保存および閲覧するためのサービスです。 これにより把握できることは「Google AI Studio 経由の Gemini API が使用されている可能性が高いプロジェクト ID の一覧」です。把握できるスコープは「Google Cloud 組織」です。 この操作を行うには、該当の Google Cloud 組織の組織ルートレベルで、クラウド アセット閲覧者( roles/cloudasset.viewer )ロールおよび Service Usage ユーザー( roles/serviceusage.serviceUsageConsumer )ロールが必要です。権限が不足している場合、後述の手順で組織ルートノードを選択できません。 Google AI Studio の利用者が API キーを使って Gemini API を使用するには、キーの所属する Google Cloud プロジェクトで generativelanguage.googleapis.com API(以下、Gemini API)が有効になっている必要があります。こういったプロジェクトを Cloud Asset Inventory で一覧化できます。 Gemini API が有効になるタイミングは、以下が考えられます。 Google AI Studio の UI で新規プロジェクトを作成した時点で、そのプロジェクトでは Gemini API が有効化される Google AI Studio の UI でプロジェクトをインポートし、同プロジェクトを指定して API キーを作成した時点で、そのプロジェクトでは Gemini API が有効化される よって、同 API が有効になっているプロジェクトでは、Google AI Studio の API キーが発行されたことがある可能性が高いといえます。課金レポートを確認する方式と比べ、まだ課金が発生していなくても、疑わしいプロジェクトを特定できます。 これらのプロジェクト一覧を表示する手順は、以下のとおりです。 Google Cloud コンソール( https://console.cloud.google.com/ )にログイン Cloud Asset Inventory の「リソース」画面に遷移( https://console.cloud.google.com/iam-admin/asset-inventory/resources ) コンソール画面左上部のプロジェクトセレクタで、組織ルートノードを選択 アセット一覧表の上部のフィルタに services/generativelanguage.googleapis.com と入力 Cloud Asset Inventory による API 有効化済みプロジェクトの一覧 なお、Google AI Studio の UI で新規プロジェクトを作成した場合、以下のようなプロジェクトが自動作成されます。このような特徴を持つプロジェクトでは、Gemini API が利用されている可能性が高いことがすぐにわかります。 プロジェクト ID が右のような形式になっている: gen-lang-client-0123456789 組織ルートノード直下に作成される(フォルダに格納されていない) 組織、フォルダ、プロジェクトの一覧は、Resource Manager の「リソースの管理」画面( https://console.cloud.google.com/cloud-resource-manager )で確認できます。 API キーの制限の徹底 概要 Google AI Studio で発行する API キーは、 Google Cloud プロジェクトの API キー です。Google Cloud プロジェクトでは、Google Maps API や Firebase API を実行するためだったり、Google Workspace の各種 API を実行するためなど、様々な理由で API キーが発行される可能性があります。Google AI Studio の UI で発行する API キーは、Google AI Studio 専用というわけではなく、これらと同じものです。 前述のとおり、以下の流れで、過去に発行した API キーが原因で Gemini API が大量にリクエストされてしまう可能性があります。 Google Maps や Firebase のために、公開が前提である API キーを発行した ソースコードへのハードコード等により API キーが他人に知られる API キーが所属するプロジェクトで後から Gemini API が有効化される 上記の場合にも、Gemini API 等が不正利用されることを防ぐため、API キーには制限を設定する必要があります。 API キーの所在の把握 Google Cloud 組織配下で発行されている API キーの一覧を確認するには、 Cloud Asset Inventory が使用できます。 これにより把握できることは「過去に発行された API キーの一覧」「その API キーを格納している Google Cloud プロジェクトの一覧」です。把握できるスコープは「Google Cloud 組織」です。 この操作を行うには、該当の Google Cloud 組織の組織ルートレベルで、クラウド アセット閲覧者( roles/cloudasset.viewer )ロールおよび Service Usage ユーザー( roles/serviceusage.serviceUsageConsumer )ロールが必要です。権限が不足している場合、後述の手順で、プロジェクトセレクタにおいて組織ルートノードを選択できません。 以下の手順により、発行済みの API キーの一覧と、その所属プロジェクト等を確認できます。 Google Cloud コンソール( https://console.cloud.google.com/ )にログイン Cloud Asset Inventory の「リソース」画面に遷移( https://console.cloud.google.com/iam-admin/asset-inventory/resources ) コンソール画面左上部のプロジェクトセレクタで、組織ルートノードを選択 アセット一覧表の上部のフィルタに apikeys.googleapis.com と入力 Cloud Asset Inventory による発行済み API キーの一覧 API キーの制限 API キーの所在を把握したら、プロジェクトの担当者に連絡し、API キーに 制限 (restrictions)が設定されていることを確認してください。API キーの制限とは、アクセス可能な API を限定する設定のことです。 参考 : API キーに制限を追加する | API Keys API Documentation 前述のとおり、Google Maps や Firebase など、他の目的で発行された API キーが他人に不正利用された場合、制限がかけられていないキーについては、そのキーを使って Gemini API などへのリクエストが可能です。 後述のプロジェクト分離などは別途検討する前提のうえで、API キーには制限を設定することが望ましいです。 以下の手順で、API キーの制限の状態を確認したり、制限を追加できます。 Google Cloud コンソール( https://console.cloud.google.com/ )にログイン 「API とサービス」画面の「認証情報」タブに遷移( https://console.cloud.google.com/apis/credentials ) コンソール画面左上部のプロジェクトセレクタで、対象のプロジェクトを選択 表「API キー」を確認。制限が設定されていればキーの名前の左部に緑色のチェックマークが表示される。名前をクリックすることで制限の詳細を確認したり、制限を追加できる Google Cloud における API キーの一覧 ベストプラクティスへの準拠 使用していない API キーを削除する、アプリケーションごと(用途ごと)にキーを分離する、定期的なローテーションなど、以下のドキュメントに記載のベストプラクティスに準拠してください。 参考 : API キーの管理に関するベスト プラクティス | Authentication | Google Cloud Documentation また、以下のような基本的な事項に注意を払ってください。Google Maps や Firebase 利用用途で API キーを発行した場合、「API キーはシークレット(機密情報)ではない」とされていますが、少なくとも Gemini API では課金を伴う API リクエストの認証情報として使用されている以上、シークレットあるいはそれに準じるものとして扱うことが望ましいといえます。 原則としてソースコードに API キーをハードコーディングしない。する必要がある場合、API キーに制限をかける GitHub などの公開リポジトリに API キーをアップロードしない インターネット公開されているストレージに、API キーを配置しない 社内のチャットや、メール、ポータルサイト、その他多数の目に触れる場所に API キーを貼り付けない その他、公共のインターネットからアクセスできる場所に API キーを配置しない Google AI Studio から Vertex AI への移行 Vertex AI を第一選択肢に Gemini を API 経由で利用する場合は、Google AI Studio ではなく、代わりに Vertex AI と サービスアカウント を用いた Gemini 利用を第一の選択肢として検討してください。 Google AI Studio は「個人開発者、研究者、学生」等を対象としたサービスとされており、企業で Gemini API を利用する場合は、Vertex AI API 経由で Gemini を呼び出すことが推奨されます。 参考 : Gemini Enterprise の比較 - Google AI Studio、Vertex AI 参考 : Google AI Studio vs Vertex AI。違いや選び方を解説 - G-gen Tech Blog Vertex AI を使用することで、Google Cloud のサービスアカウントを使って認証できるようになります。サービスアカウントを使うと、Cloud Run や Compute Engine といった Google Cloud のコンピュートプラットフォーム上からは、テキスト形式の認証情報を 使うことなく 、Gemini を API 経由で呼び出すことができます。 この仕組みは Application Default Credentials(ADC)と呼ばれ、最も推奨される方法です。 参考 : アプリケーションのデフォルト認証情報を構成する | Generative AI on Vertex AI | Google Cloud Documentation 移行する場合 Google AI Studio から Vertex AI への移行については、以下のドキュメントを参照してください。 参考 : Google AI Studio から Vertex AI に移行する | Generative AI on Vertex AI | Google Cloud Documentation 追加のセキュリティ施策 Vertex AI を使用することで、Google Cloud に用意されている以下のような追加のセキュリティ施策を適用可能です。 Workload Identity を使うことで、Amazon Web Services(AWS)の IAM ユーザーなど、他のプラットフォームの認証情報に基づいて API を呼び出すことができます。 参考 : Workload Identity 連携 | Identity and Access Management (IAM) | Google Cloud Documentation VPC Service Controls という仕組みを使うと、呼び出し元の IP アドレスを制限したり、アイデンティティやそのコンテキスト情報などに基づいた、コンテキストアウェアなアクセス制限を適用できます。 参考 : VPC Service Controls の概要 | Google Cloud Documentation 参考 : VPC Service Controlsを分かりやすく解説 - G-gen Tech Blog Google AI Studio の使用禁止 管理者設定による使用禁止 Google Workspace または Cloud Identity を使っている場合、管理者設定により、組織で管理している Google アカウントに対して、Google AI Studio の使用を禁止できます。 これにより、開発者が無償版の API キーを発行して、組織の機密情報や顧客情報、個人情報等を Google に送信してしまうことを防ぐことができるほか、当記事の冒頭で紹介したような過剰請求を未然に防止できます。設定手順は以下の公式ドキュメントを参照してください。 参考 : その他の Google サービスを有効または無効にする | Advanced Google Workspace 管理画面における Google サービスのオン・オフ 短絡的に禁止しない Google AI Studio を禁止することはセキュリティと統制の観点で有用ですが、その代わり、組織における Google の生成 AI の検証やスピーディーな業務導入を阻害することにも繋がります。 短絡的に禁止するのではなく 、Google AI Studio をセキュアに使用するためのガイドラインや手順を整備するといった代替策や、禁止する場合でも社内で Vertex AI をスピーディーに使用開始するための手順を整備するなど、 組織の AI 活用を阻害せずにセキュリティを確保する 方法の検討が望まれます。 目的別のプロジェクト分離 「事象の原因」の章で紹介したように、あとから Google Cloud プロジェクトで API が有効化されることによって、API キーを発行した当初には想定していなかった API が呼び出されることを防ぐには、 目的別にプロジェクトを分離 することが重要です。 Google Maps 用、Firebase 用、Gemini API 用など、用途・目的別に Google Cloud プロジェクトを分けて作成するほか、本番環境、ステージング環境、開発環境など、環境別にプロジェクトを分けるのも有効です。 プロジェクトを細かい粒度で分けることで、万が一 API キーやその他の認証情報が流出した際にも、 影響範囲を最小化 し、キーや認証情報の停止などの 対策 を適切な粒度で、かつスピード感を持って実行できるようになります。 杉村 勇馬 (記事一覧) 執行役員 CTO 元警察官という経歴を持つ IT エンジニア。クラウド管理・運用やネットワークに知見。AWS 認定資格および Google Cloud 認定資格はすべて取得。X(旧 Twitter)では Google Cloud や Google Workspace のアップデート情報をつぶやいています。 Follow @y_sugi_it
はじめに KINTOテクノロジーズの大沼です。 モビリティサービス「my route」アプリの開発に従事しています。 本記事では、AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細とハマった点・注意点をまとめました。 こちら大杉さんの記事 では、Tink を使用したケースとパフォーマンス検証を紹介しているのでぜひご一読ください。 💬 実装の前にディスカッション 🔍 本当に暗号化が必要なのか DroidKaigi 2025のyanzamさんのお話 でも触れられてましたが、Keystore がクラッシュするのは黙認しつつ最低限の機会頻度に抑えたいので、既存で暗号化しているデータが本当に暗号化する必要があるのかの議論をチームメンバーと交わしました。 案の定、不要に暗号化しているものもあり、議論することで最適なものに絞ることができました。 🏗️ アーキテクチャ セキュリティに関するリファクタリングのコードレビューは、心理負荷が高いと考えています。 私のチームはこういう時、大枠の実装方針を事前に共有し合うことで、コードレビュー時の認識違いや負担が減らせます。 今回、データの暗号化とインターフェースを以下のようなスライドで共有し、大きな齟齬なくレビューを進めることができました。 🛠️ 実装の流れ ここからは、実際の実装手順を以下の流れで解説します。 依存関係の追加 — DataStoreライブラリの導入 Keystoreを使った暗号化キーの生成 — AES/GCMの鍵をAndroid Keystoreで安全に管理 Cipherを使った暗号化・復号化 — 初期化ベクトル(IV)の扱いを含む暗号処理の実装 DataStoreへの保存 — 暗号化したデータをPreferences DataStoreで永続化し、Flowで読み出す 📚 依存関係の追加 ライブラリにDataStoreを追加します。 dependencies { // DataStore implementation("androidx.datastore:datastore-preferences:1.1.7") } 🔑 Keystoreを使った暗号化キーの生成 import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore import javax.crypto.KeyGenerator ... fun getOrCreateSecretKey(): SecretKey? { try { // KeyStoreのインスタンス生成 val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE_PROVIDER).apply { load(null) // KeyStoreを初期化するための必須の呼び出し } // KeyStoreにプロダクトの鍵が存在するか確認し、あれば取得し返す if (keyStore.containsAlias(PROJECT_KEY_STORE_ALIAS)) { val entry = keyStore.getEntry(PROJECT_KEY_STORE_ALIAS, null) if (entry is KeyStore.SecretKeyEntry) { return entry.secretKey } } // KeyStoreにプロダクトの鍵が存在しなければ生成して保存し返す val params = KeyGenParameterSpec.Builder( PROJECT_KEY_STORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .build() val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE_PROVIDER, ) keyGenerator.init(params) return keyGenerator.generateKey() } catch (e: Exception) { Firebase.crashlytics.recordException(e) return null } } 🔐 Cipherを使った暗号化・復号化 import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import java.util.Base64 import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec interface CryptographyManager { fun encrypt(plaintext: String): String fun decrypt(encryptedString: String): String } private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val IV_SIZE_BYTES = 12 private const val TAG_SIZE_BITS = 128 class CryptographyManagerImpl : CryptographyManager { override fun encrypt(plaintext: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) val ivAndCiphertext = cipher.iv + ciphertext // IVと暗号文をバイト配列として結合 Base64.getEncoder().encodeToString(ivAndCiphertext) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } override fun decrypt(encryptedString: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) val ivAndCiphertext = Base64.getDecoder().decode(encryptedString) // 復号化時に保存したIVを使う val spec = GCMParameterSpec(TAG_SIZE_BITS, ivAndCiphertext, 0, IV_SIZE_BYTES) cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec) val plaintext = cipher.doFinal( ivAndCiphertext, IV_SIZE_BYTES, ivAndCiphertext.size - IV_SIZE_BYTES, ) String(plaintext, Charsets.UTF_8) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } } 💾 DataStoreへの保存 import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map data class SecureDataPreferences( val textData: String, ) object PreferencesKeys { private val TEXT_KEY = stringPreferencesKey("encrypted_text") } private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "encrypted_prefs") class SecureDataRepository( private val cryptographyManager: CryptographyManager ) { suspend fun saveTextData(data: String) { val encryptedData = cryptographyManager.encrypt(data) dataStore.edit { preferences -> preferences[TEXT_KEY] = encryptedData } } private val secureDataFlow: Flow<SecureDataPreferences> = secureDataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } } .map { it.mapSecureDataPreferences() } private fun Preferences.mapSecureDataPreferences(): SecureDataPreferences { return SecureDataPreferences( textData = this[PreferencesKeys.TEXT_KEY]?.let { cryptographyManager.decrypt(it) } ?: "", // ... Other data ) } suspend fun getTextData(): String { return try { withTimeout(3000L) { secureDataFlow.map { it.textData }.first { it.isNotBlank() } } } catch (_: TimeoutCancellationException) { "" } catch (_: NoSuchElementException) { "" } } } ⚠️ ハマった点・注意点 1. 初期化ベクトル(IV)の保存 暗号化時に生成されるIV(Initialization Vector)は、復号化時に必須です。 IVは暗号文と一緒に保存する必要があります。IVは秘密情報ではないため、平文で保存しても問題ありません。 ハマったポイント: 最初の実装でIVを保存し忘れ、復号化時に javax.crypto.AEADBadTagException が発生しました。 2. KeyStoreのキーのライフサイクル Android Keystoreに保存されたキーは、アプリをアンインストールすると削除されます。 また、デバイスのロック画面が解除されるまでキーにアクセスできない設定も可能です( setUserAuthenticationRequired(true) )。 注意点: keyが存在しない場合の処理を適切に実装する必要があります。 3. GCMモードのタグ長 GCM(Galois/Counter Mode)を使用する場合、タグ長を正しく設定する必要があります。 一般的には128ビット(16バイト)が使用されます。 4. エラーハンドリング 復号化時にはさまざまなエラーが発生する可能性があります: KeyPermanentlyInvalidatedException : キーが無効化された AEADBadTagException : 暗号文が改ざんされた、またはIVが間違っている InvalidKeyException : キーが無効 これらのエラーをハンドリングし、必要に応じてデータをクリアし再生成するなどの対応が必要です。 5. DataStoreの非同期処理 DataStoreはすべての操作が非同期で行われます。 CoroutineまたはFlowを使用して適切に処理する必要があります。 DataStoreのソースコード内で、最新の値を1つだけ取得できる data.first() を使用することを推奨しています。 // ViewModelでの使用例 viewModelScope.launch { repository.saveTextData(sensitiveData) } // Flowの監視 repository.secureDataFlow.map { it.textData }.first { it.isNotBlank() } 6. 無限待機の防止 DataStoreはディスクI/Oを伴う非同期処理です。first { it.isNotBlank() } は条件に一致する値が来るまで無限に待機しますが、 もしディスク読み込みが遅延したり、トークンが空のままだと、アプリがフリーズする可能性があり、タイムアウトを追加しました。 7. ProGuard/R8の設定 DataStore 1.1.5以降では、ProGuardルールがライブラリに内包されています。 巷の記事で ProGuardルール の記載が必要なことを目の当たりにしましたが、ルール記載なくリリースビルドしたところクラッシュせず、なぜ? となっていたところ、リリースノート確認し気づきました。 今回、1.1.7 を使用しているため、DataStore専用のProGuardルールを追加する必要はありません。 https://developer.android.com/jetpack/androidx/releases/datastore バージョン1.2.0-beta01で修正された問題として記載: "Fix java.lang.UnsatisfiedLinkError when using DataStore in an app which is optimized with R8" バージョン1.1.5で修正: "missing Proguard rules issue in the Android artifact of datastore-preferences-core" 8. 標準のSharedPreferencesMigrationが使えない EncryptedSharedPreferencesは特殊な暗号化を使用していて、 標準のマイグレーションでは暗号化されたままのデータが転送される また、EncryptedSharedPreferencesは 読み取り時に自動復号化されるのに対し、 今回は CryptographyManagerによる手動暗号化が必要です。 この部分を認知することができず、標準の標準のSharedPreferencesMigrationで実装し、テストしたところ復号できず判明しました。 マイグレーション時に適切な暗号化変換を実装しました。 まとめ 本記事では、Android Keystore、Cipher、DataStoreを組み合わせた秘匿情報の暗号化・永続化の実装について紹介しました。 実装前のディスカッションが重要 : そもそも暗号化が必要なデータかをチームで議論することで、Keystoreへのアクセス頻度を最小限に抑えられた Keystoreの鍵管理 : AES/GCMモードでの鍵生成とIV(初期化ベクトル)の保存を適切に行う必要がある DataStoreとの組み合わせ : Flowベースの非同期読み出しに対応するため、タイムアウトや無限待機の防止策が必要 EncryptedSharedPreferencesからの移行 : 標準のSharedPreferencesMigrationでは暗号化方式の違いにより復号できないため、手動でのマイグレーション実装が必要 Keystoreのクラッシュは完全には避けられませんが、暗号化対象を最適化し、エラーハンドリングを適切に実装することで、安定したセキュアなデータ管理を実現できました。 📣 追記: DataStore 1.3.0-alpha07 で暗号化サポートが追加 本記事の執筆後、 DataStore 1.3.0-alpha07 (2026年3月11日リリース)で、 Tinkライブラリを使用した暗号化サポート が新たに追加されました。 新しい androidx.datastore:datastore-tink アーティファクトにより、 AeadSerializer を使って既存のシリアライザをラップするだけで暗号化が実現できます。 val aeadSerializer = AeadSerializer( aead = keysetHandle.getPrimitive( RegistryConfiguration.get(), Aead::class.java, ), wrappedSerializer = ExistingSerializer, associatedData = "settings.json".encodeToByteArray(), ) 本記事で紹介したKeystore + Cipher による手動実装と比較すると、Tink統合によりボイラープレートが大幅に削減されます。ただし、現時点ではalpha版であるため、プロダクション導入の際はAPIの安定性を考慮する必要があります。今後のstable版リリースに注目です。 参考資料 Android Keystore System Jetpack DataStore 暗号化されたファイルの使用 Android セキュリティのベスト プラクティス DataStore 1.3.0-alpha07 リリースノート
.table-of-contents > li > ul { display: none; } こんにちは、MA部配信基盤ブロックの田島です。ZOZOTOWNではユーザへのコミュニケーション手段の1つとしてアプリへのPush通知を活用しており、配信にはFirebase Cloud Messaging(以降、FCM)を利用しています。 FCMではPush通知の送信先となるデバイスごとに「FCMトークン」と呼ばれる一意の識別子が発行され、このトークンを宛先としてFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 FCMでは無効なトークンに対して UNREGISTERED エラーを返します。Firebaseの公式ドキュメントでは、このエラーが返されたトークンを無効として扱うことが推奨されています。しかし、我々の調査により、 一度 UNREGISTERED エラーを受けたトークンがその後復活し、再び有効になるケース の存在を確認しました。復活したトークンで配信すると正常にPush通知が届きクリックイベントも取得できることから、確実に有効なトークンであることを確認しています。 本記事では、このトークン復活の実態調査と、FCMの validate_only APIを活用したエラートークン管理の精緻化について紹介します。 目次 目次 背景と課題 FCMトークンとは 既存のエラートークン管理の問題 エラートークン復活の調査 調査内容 調査方法 調査結果 トークン復活に関する補足 方針の検討 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 方針2: 即時エラー登録 + validate_onlyで定期解除 決定した方針 FCM validate_only フラグを利用したトークンの検証 validate_only フラグ 動作検証 エラートークンの収集と検証バッチの実装 テーブル設計 エラートークンテーブル(error_fcm_tokens) 再有効化テーブル(reactivated_fcm_tokens) エラートークンの収集・再検証ワークフロー 1. エラートークンの収集 2. 検証用一時テーブルの作成 3. エラートークンの再検証(並列処理) 検証対象トークンの取得 シャード単位の検証処理 FCM APIによるトークン検証 4. エラートークンテーブルの更新 パフォーマンス 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 通常運用の開始 まとめ 最後に 背景と課題 FCMトークンとは 最初にも紹介しましたが、FCMトークンとは、FCMがPush通知の送信先を識別するために、アプリがインストールされた各デバイスに対して発行する一意の識別子です。アプリの初回起動時にFCM SDKがこのトークンを生成し、このトークンを指定してFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 配信フローとしては、サーバからFCMにメッセージリクエストが送られます。FCMはプラットフォーム固有の転送層(Androidの場合はATL、iOSの場合はAPNs)を経由して対象デバイスにメッセージを届けます。 FCMトークンは永続的でなく、以下のような理由で無効化や更新が発生します。 トークンがリフレッシュされた場合 トークンの保持期間を超過した場合 アプリがアンインストールされた場合 無効になったトークンを使ってFCMにリクエストを行うと、 UNREGISTERED エラーが返されます。 firebase.google.com 既存のエラートークン管理の問題 Firebaseの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンを無効として扱うことがベストプラクティスとして紹介されています。 firebase.google.com こちらに則り、 UNREGISTERED エラーが返されたトークンを無効として記録し、以降の配信対象から除外していました。しかし、 UNREGISTERED エラーを受けたトークンがその後再び有効になるケースの存在を確認しました。この場合、本来配信すべきユーザにPush通知が届かなくなってしまいます。 まずはユーザへの配信が確実にできることを優先し、エラートークンの登録処理を一時的に停止した上で、復活の頻度や傾向を正確に把握するための調査を実施しました。 エラートークン復活の調査 調査内容 エラートークンの管理方針を決めるにあたり、以下の点を調査しました。 SUCCESS → UNREGISTERED → SUCCESS が発生する頻度 UNREGISTERED が何回連続した後 SUCCESS へ復帰するケースがあるか UNREGISTERED がどれくらいの期間続いた後 SUCCESS へ復帰するケースがあるか SUCCESS に復帰後、どれくらいの回数成功が続くか 調査方法 約2.5か月分(2025年8月以降)の配信ログを対象に、同一トークンにおけるステータス遷移を分析しました。 分析に使用した push_logs テーブルは、Push通知の配信結果を1リクエストごとに記録したログテーブルです。主なカラムは以下の通りです。 カラム名 型 説明 token STRING 配信先のFCMトークン delivered_at TIMESTAMP 配信日時 status STRING 配信結果( SUCCESS , FAILED ) status_detail STRING 失敗時の詳細( UNREGISTERED など) fcm_message_id STRING FCMが発行したメッセージID 以下のクエリで、 SUCCESS と SUCCESS の間に UNREGISTERED が挟まるケースを抽出しています。 WITH base AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id FROM `project.dataset.push_logs` WHERE TIMESTAMP_TRUNC(delivered_at, DAY) >= TIMESTAMP ( " 2025-08-01 " ) AND TIMESTAMP_TRUNC(delivered_at, DAY) <= TIMESTAMP ( " 2025-10-15 " ) AND token IS NOT NULL AND status IN ( ' SUCCESS ' , ' FAILED ' ) ), -- トークンごとに時系列でインデックスを付与 ordered AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id, ROW_NUMBER() OVER (PARTITION BY token ORDER BY delivered_at, fcm_message_id) AS rn FROM base ), -- 累積のUNREGISTERED失敗数などを付与 ord AS ( SELECT o.*, SUM ( CASE WHEN o.status = ' FAILED ' AND o.status_detail = ' UNREGISTERED ' THEN 1 ELSE 0 END ) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cum_unreg_failed, MIN ( IF (o.status != ' SUCCESS ' , o.rn, NULL )) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS next_non_success_rn, COUNT (*) OVER (PARTITION BY o.token) AS total_rows FROM ordered o ), -- SUCCESS行から直前のSUCCESSとの関係を取得 success_pairs AS ( SELECT s.token, s.rn AS success_rn, s.delivered_at AS success_at, LAG(s.rn) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_rn, LAG(s.delivered_at) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_at FROM ord s WHERE s.status = ' SUCCESS ' ), -- 直前SUCCESS〜今回SUCCESSの間にあるUNREGISTERED失敗件数を算出 final AS ( SELECT sp.token, sp.prev_success_at, sp.success_at AS recover_success_at, (oc.cum_unreg_failed - COALESCE (op.cum_unreg_failed, 0 )) AS unreg_failed_between_successes, ( COALESCE (oc.next_non_success_rn, oc.total_rows + 1 ) - oc.rn) AS consecutive_success_count_after FROM success_pairs sp JOIN ord oc ON oc.token = sp.token AND oc.rn = sp.success_rn LEFT JOIN ord op ON op.token = sp.token AND op.rn = sp.prev_success_rn WHERE sp.prev_success_rn IS NOT NULL ) SELECT * FROM final WHERE unreg_failed_between_successes > 0 ORDER BY recover_success_at; 調査結果 項目 結果 SUCCESS → UNREGISTERED → SUCCESS の発生頻度 2.5か月で約230件 UNREGISTERED の最大の連続回数 約80回 UNREGISTERED が続く最大期間 約14日 SUCCESS 復帰後の成功回数 ケースにより異なる この結果から、 UNREGISTERED の返されたトークンが復活するケースは確かに存在することがわかりました。また、 UNREGISTERED の連続する回数・期間も把握できました。 トークン復活に関する補足 FCMの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンについて「it will never again be valid(二度と有効にはならない)」と明記されています。そのため、即座に削除することが推奨されています。 firebase.google.com ただし、FCMのエラーコードに関するドキュメントでは「This usually means that the token used is no longer valid and a new one must be used.(通常、使用されたトークンはもはや有効ではなく、新しいトークンを使用する必要があることを意味します)」という表現になっており、「usually」という留保がついています。 firebase.google.com 実際に復活したトークンを使って配信すると正常にPush通知が届き、クリックイベントも取得できることを確認しています。トークンがリフレッシュされて新しいものが発行されたわけでもなく、同一のトークンがそのまま再び有効になっていました。公式ドキュメントの記述と実際の挙動に乖離がある状況です。 なお、この挙動は2026年3月時点でも確認されています。将来的にFCM側で修正される可能性もあるため、最新の挙動については各自で検証されることをお勧めします。 方針の検討 調査により、トークンの復活は2.5か月で約230件と少数ながら確実に発生しており、最長で約14日間 UNREGISTERED が続いた後に復活するケースも確認されました。 この結果を踏まえると、エラートークンの管理には以下の2点を両立させる必要があります。 無効なトークンへの無駄な配信を早期に止めること 復活する可能性のあるトークンを誤って永久に除外しないこと これらを考慮し、以下の2つの方針を検討しました。 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 1つめの方針は、1か月ずっと UNREGISTERED となっているトークンをエラー扱いにする方式です。調査結果から14日以上 UNREGISTERED が続くケースはなかったため、1か月の閾値で安全にエラートークンを判定できます。これにより、本当に無効となったトークンのみをエラートークンとして保持できます。 方針2: 即時エラー登録 + validate_onlyで定期解除 従来通り UNREGISTERED になったトークンをエラー扱いとしつつ、定期的に validate_only でトークンの有効性を再検証し、復活したトークンをエラーリストから除外する方式です。 validate_only については後ほど説明します。これにより、無効と判定したトークンを即時無効にしつつ、復活したトークンに対しても配信を継続できます。 決定した方針 両方針を比較した結果、以下の理由から 方針2 を採用しました。 方針1だと、一度 UNREGISTERED となったトークンが復活しない場合、1か月の間無効なトークンに配信し続けてしまう 初回の validate_only 検証を既存の全エラートークンに実施することで、これまでに蓄積したエラートークンを有効活用できる 既存のエラートークン登録フローを大きく変更する必要がない FCM validate_only フラグを利用したトークンの検証 validate_only フラグ FCMの messages.send API(FCMにPush通知送信を依頼するAPI)には validate_only フラグがあります。これを true に設定すると、実際にメッセージを配信せずにトークンの有効性のみを検証できます。 動作検証 validate_only (Firebase Admin SDKでは dry_run パラメータに対応)が本当に配信しないことを事前に検証しました。 dry_run=True の場合、レスポンスの message_id が fake_message_id となり、実際のメッセージ配信は行われません。これにより、安全にトークンの有効性を確認できることが実証されました。 dry_run の詳細な検証については、以下の記事にまとめています。 qiita.com エラートークンの収集と検証バッチの実装 ここからは、エラートークンの収集と検証の方法について紹介します。 テーブル設計 本施策では主に2つのテーブルを使用します。 エラートークンテーブル( error_fcm_tokens ) UNREGISTERED エラーが返されたトークンを記録するテーブルです。FCMトークンそのものをキーとして管理することで、トークンの有効性を直接的に判定できるようにしています。 カラム名 型 説明 fcm_token STRING エラーとなったFCMトークン first_errored_at TIMESTAMP 初めて UNREGISTERED エラーが発生した日時 registered_at TIMESTAMP エラートークンとして登録した日時 再有効化テーブル( reactivated_fcm_tokens ) 一度エラーとなったが、 validate_only による再検証で有効と判定されたトークンの履歴を記録するテーブルです。 カラム名 型 説明 fcm_token STRING 再有効化されたFCMトークン validated_at TIMESTAMP validate_only で検証した日時 reactivated_at TIMESTAMP エラートークンテーブルから削除し再有効化した日時 エラートークンの収集・再検証ワークフロー エラートークンの収集と再検証を日次で行うワークフロー( refresh_error_fcm_tokens )を作成しました。バッチ処理にはワークフローエンジンの Digdag を使用しています。Digdagのワークフロー定義は以下の通りです。Digdagでは + で始まるブロックがタスクを表し、上から順に実行されます。 timezone : Asia/Tokyo schedule : daily> : 00:00:00 # 毎日0時に実行 # ワークフロー全体で使う変数の定義 _export : # 検証結果を格納する一時テーブル名(実行日ごとに一意になるようにする) validated_fcm_tokens_temp_table_id : "project.temp.validated_fcm_tokens_temp_${moment(session_time).format('YYYYMMDD')}" # 並列処理のシャード数 total_shards : 50 # 1. 配信ログからUNREGISTEREDエラーのトークンを収集し、エラートークンテーブルに登録 +collect_fcm_error_tokens : py> : app.collect_fcm_error_tokens # 2. 検証結果を格納する一時テーブルを作成 +create_temp_table : py> : app.refresh_error_fcm_tokens.create_validation_temp_table # 3. エラートークンを50シャードに分割し、並列でFCM APIに検証リクエストを送信 # loop>: 0〜49のインデックス(${i})で繰り返し、_parallel: trueで全シャードを同時実行 +validate_fcm_tokens_parallel : _parallel : true loop> : ${total_shards} _do : +validate_shard : py> : app.refresh_error_fcm_tokens.validate_fcm_tokens_shard shard_index : ${i} total_shards : ${total_shards} # 4. 一時テーブルの検証結果をもとに、有効なトークンをエラートークンテーブルから削除し、 # 再有効化テーブル(reactivated_fcm_tokens)に記録 +update_error_and_reactivated_fcm_tokens : py> : app.refresh_error_fcm_tokens.update_error_and_reactivated_fcm_tokens 以下でそれぞれについて具体的に説明します。 1. エラートークンの収集 はじめに配信ログテーブルから UNREGISTERED エラーのトークンを以下のSQLで収集し、エラートークンテーブルに追加します。このワークフローは日次で実行されますが、対象期間を直近3日間としています。これは、ワークフローが2日連続で失敗した場合でも3日目の実行で未収集分をカバーできるようにするためです。 -- エラートークンの収集クエリ SELECT token AS fcm_token, MIN (delivered_at) AS first_errored_at, CURRENT_TIMESTAMP AS registered_at FROM `project.ma_batch.push_logs` AS push_logs LEFT OUTER JOIN `project.push.error_fcm_tokens` AS target ON push_logs.token = target.fcm_token WHERE status = " FAILED " AND status_detail = " UNREGISTERED " -- 日次実行だが、2日連続WF失敗時でも3日目に回復できるよう3日分のバッファを確保 AND DATE (delivered_at) >= DATE_ADD( CURRENT_DATE ( ' Asia/Tokyo ' ), INTERVAL -3 DAY) AND target.fcm_token IS NULL GROUP BY token 2. 検証用一時テーブルの作成 トークンの有効性の検証結果を格納するための一時テーブルを作成します。各シャードがFCM APIの検証結果をこのテーブルに書き込み、最後にまとめてエラートークンテーブルを更新します。 DROP TABLE IF EXISTS `{validated_fcm_tokens_temp_table_id}`; CREATE TABLE `{validated_fcm_tokens_temp_table_id}` ( fcm_token STRING NOT NULL , -- 検証対象のFCMトークン validated_at TIMESTAMP NOT NULL , -- 検証日時 valid BOOLEAN NOT NULL , -- 有効かどうか error_code STRING, -- 無効だった場合のエラーコード ); 3. エラートークンの再検証(並列処理) エラートークンテーブルに登録済みのトークンに対し、FCMの validate_only APIでトークンの有効性を再検証します。この処理は50シャードに分割して並列実行されます。 検証対象トークンの取得 各シャードが担当するトークンを取得するSQLは以下の通りです。 FARM_FINGERPRINT でトークンをハッシュ化し、シャード数で剰余を取ることで均等に分割しています。また、 first_errored_at が直近30日以内のトークンのみを対象とし、復活の見込みが低い古いトークンへの無駄な検証を避けています。この期間は、調査でわかった UNREGISTERED が続く最大期間の約14日に余裕をもたせて設定しています。 SELECT error_tokens.fcm_token, error_tokens.first_errored_at FROM `project.push.error_fcm_tokens` AS error_tokens LEFT JOIN `{validated_fcm_tokens_temp_table_id}` AS temp_tokens ON error_tokens.fcm_token = temp_tokens.fcm_token WHERE -- 直近30日以内に登録されたエラートークンのみを対象 error_tokens.first_errored_at >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL 30 DAY) AND -- FARM_FINGERPRINTでトークンをハッシュ化し、シャードに均等分割 MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND -- リトライ時に既に処理済みのトークンを除外 temp_tokens.fcm_token IS NULL シャード単位の検証処理 各シャードでは上記のSQLで取得したエラートークンに対し、FCMの dry_run ( validate_only に対応)でトークンの有効性を検証しています。検証対象のトークン数は数百万件に及ぶため、メモリ効率を考慮して5,000件ごとのバッチに分割して処理しています。検証結果は一時テーブルに書き込まれます。 def validate_fcm_tokens_shard (self, shard_index, total_shards, ...) -> None : # リトライ時に途中から再開できるよう、5,000件ずつ処理する BATCH_SIZE = 5000 # BigQueryからこのシャードが担当するエラートークンを取得 # (例: shard_index=0, total_shards=50 なら、全体の1/50を担当) result = self._bq_client.execute_bigquery_result( query_path= "get_error_fcm_tokens_shard.sql" , params={ "shard_index" : shard_index, "total_shards" : total_shards}, ) fcm_client = FCMClient(fcm_gcp_project) # 5,000件ずつFCM APIで検証し、結果を一時テーブルに書き込む for batch_tokens in self._create_batches(result, BATCH_SIZE): valid_tokens, invalid_tokens = fcm_client.validate_tokens_batch(batch_tokens) self._insert_validation_results(valid_tokens, invalid_tokens) FCM APIによるトークン検証 FCMトークンの実際の検証では、Firebase Admin SDKの messaging.send_each を dry_run=True で呼び出しています。実際にメッセージを配信せずにトークンの有効性のみを検証できます。 send_each は1リクエストあたり最大500件のため、500件単位で分割してリクエストを送信しています。 class FCMClient : BATCH_SIZE = 500 # send_eachの1リクエストあたりの最大件数 def validate_tokens_batch (self, tokens: List[ str ]) -> Tuple[List[ str ], List[Tuple[ str , str ]]]: valid_tokens = [] # 有効と判定されたトークンのリスト invalid_tokens = [] # 無効と判定されたトークンと、そのエラーコードのリスト # 500件ずつに分割してFCM APIにリクエスト for i in range ( 0 , len (tokens), self.BATCH_SIZE): batch = tokens[i:i + self.BATCH_SIZE] # 各トークンに対してダミーのメッセージオブジェクトを生成 messages = [ messaging.Message(token=token, data={ 'validation' : 'true' }) for token in batch ] # dry_run=True により実際の配信は行わず、トークンの有効性のみ検証 batch_response = messaging.send_each(messages, dry_run= True ) # レスポンスからトークンごとの有効/無効を判定 for idx, response in enumerate (batch_response.responses): token = batch[idx] if response.success: valid_tokens.append(token) else : error_code = response.exception.code if response.exception else "Unknown" invalid_tokens.append((token, error_code)) return valid_tokens, invalid_tokens 4. エラートークンテーブルの更新 全シャードの検証が完了した後、一時テーブルの結果をもとにエラートークンテーブルと再有効化テーブルをトランザクション内で一括更新します。有効と判定されたトークンを再有効化テーブルにMERGEし、エラートークンテーブルから削除しています。 BEGIN TRANSACTION; -- 一時テーブルから有効と判定されたトークンを重複排除して抽出 CREATE TEMP TABLE deduped_tokens AS SELECT DISTINCT fcm_token, MAX (validated_at) AS validated_at, CURRENT_TIMESTAMP () AS reactivated_at FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE GROUP BY fcm_token; -- 有効なトークンを再有効化テーブルに記録 MERGE `project.push.reactivated_fcm_tokens` AS target USING deduped_tokens AS source ON (target.fcm_token = source.fcm_token AND target.validated_at = source.validated_at) WHEN NOT MATCHED THEN INSERT (fcm_token, validated_at, reactivated_at) VALUES (source.fcm_token, source.validated_at, source.reactivated_at); -- 有効なトークンをエラートークンテーブルから削除 DELETE FROM `project.push.error_fcm_tokens` WHERE fcm_token IN ( SELECT DISTINCT fcm_token FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE ); COMMIT TRANSACTION; パフォーマンス 上記の処理がどれくらいの時間で完了するのか、パフォーマンス計測をした結果は以下の通りです。 対象件数 並列数 処理時間 10万件 1並列 約25分 約800万件(全量) 50並列 約50分 また、FCM APIのQuotaについても確認し、日中に実行しても問題ない余裕があることを確認しました。 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 初回実行では、過去に蓄積された全エラートークン(約754万件)を対象に検証しました。通常運用では直近30日以内のエラートークンのみを検証対象としていますが、初回は既存の全トークンの検証が必要でした。そのため、検証対象を取得するSQLの30日の条件を一時的にコメントアウトしてワークフローを実行しました。 WHERE -- 初回実行時は全期間のエラートークンを対象にするため一時的にコメントアウト -- error_tokens.first_errored_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND temp_tokens.fcm_token IS NULL 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+70件(新規エラートークン) 再検証後:約ー170件(復活トークン) 約170件のトークンが validate_only で有効と判定され、エラートークンから解除されました。 通常運用の開始 初回実行後、1か月以内に登録されたエラートークンを対象とする通常運用を開始しました。 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+6,500件(新規エラートークン) 再検証後:約ー10件(復活トークン) 日次で約10件のトークンが再有効化されていることが確認できました。 まとめ 本記事では、FCMエラートークンの管理を精緻化した取り組みについて紹介しました。 従来は UNREGISTERED エラーの返されたトークンを即時かつ永続的にエラー扱いとしていました。しかし調査の結果、一度無効になったトークンが復活するケースの存在を確認しました。そこでFCMの validate_only APIを活用した定期的な再検証の仕組みを導入し、復活したトークンを自動的にエラーリストから解除するようにしました。 この改善により、以下の効果が得られました。 無効トークンへの無駄な配信リクエストの削減によるコスト最適化 セグメントのボリューム把握の精度向上 トークン復活時の配信漏れ防止 FCMトークンの管理は、Push配信の品質とコストに直結する重要な要素です。同様の課題をお持ちの方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com










