Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Android のプッシュ通知に利用していた FCM Legacy API を HTTP v1 に移行しました

目次

背景

こんにちは、ミラティブの2024年新卒の山倉です。この記事ではプッシュ通知のAPIを移行するにあたって得た学びを共有したいと思います。

ミラティブではプッシュ通知をAndroid端末へ送信するのにFCM(Firebase Cloud Messaging)というサービスを利用しているのですが、7月22日ごろを境にFCMサーバーから送信失敗エラーが3%ほど返ってくるようになってしまいました。調査をするとFirebaseでは従来利用されていたFCM Legacy APIが段階的に廃止され、FCM HTTP v1 APIに移行する必要があったことが判明しました。この記事では、この移行作業においてどのような手順を踏んだか、どのような問題に直面したか、どのように対処したかを紹介します。(なお当移行作業は2024年9月時点で完了しています。すでに移行した会社がほとんどだとは思いますが、記念として書き残します)

FCMとは

FCM(Firebase Cloud Messaging)はGoogleが提供するクラウドメッセージングサービスで、Android端末やWebアプリ、iOS端末に対してプッシュ通知を送信することができます。FCMは以下のような特徴があります。

  • 無料で利用できる
  • 一斉送信が可能
  • 1プロジェクトにつき、最大60万トークン/分まで送信可能
  • Go言語やNode.js、Pythonなどの言語に対応したSDKが提供されている

移行の流れ

移行は以下のような手順で行いました。

  1. サービスアカウントキーを取得し、Goのライブラリに渡す
  2. メッセージの形式を決め、送信処理を書く
  3. ドキュメントのリトライポリシーに従ってエラーハンドリングを行う
  4. 3% -> 10% -> 25% -> 50% -> 100% と段階的にリリースする

以下でそれぞれについて解説していきます。

サービスアカウントキーを取得し、Goのライブラリに渡す

まずライブラリの導入ですが、単純にgo get -u firebase.google.com/goでインストールするとv3が入るので、go get -u firebase.google.com/go/v4でインストールするようにしました。 これは公式ドキュメントにも書いてありますが、v4からSDKのメジャーバージョンがパッケージ名に追加されたためです。

続いて公式ドキュメントにある手順に従い、サービスアカウントキーを取得しました。

  • Firebase コンソールで、[設定] > [サービス アカウント] を選択します。
  • [新しい秘密鍵の生成] をクリックし、[キーを生成] をクリックして確定します。
  • キーを含む JSON ファイルを安全に保管します。

https://firebase.google.com/docs/admin/setup?hl=ja より

得られたJSONファイルをサービスの実行環境に、GOOGLE_APPLICATION_CREDENTIALSという環境変数として保存しておけば、以下のようにSDKを初期化する際に自動的に認証されます。

    import (
        firebase "firebase.google.com/go/v4"
    )

    app, err := firebase.NewApp(context.Background(), nil)
    if err != nil {
        log.Fatalf("error initializing app: %v\n", err)
    }

しかし、ミラティブでは機能ごとに最低限の権限を持ったサービスアカウントを発行しているため、サービスアカウントキーを発行して以下のようにoption.WithCredentialsJSONを使って読み込んでいます。

    import (
        firebase "firebase.google.com/go/v4"
    )

    opt := option.WithCredentialsJSON([]byte(os.Getenv("クレデンシャル情報を保存している環境変数名")))
    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        panic(err)
    }

これでライブラリを初期化することができました。

メッセージの形式を決め、送信処理を書く

FCMから送れるメッセージには「通知メッセージ」と「データメッセージ」の二種類あります。それぞれ以下のような特徴があります。

  1. 通知メッセージ
    • key-valueのフォーマットが事前定義されている
    • アプリ側のコードを変えることなく通知の表示、音、バイブレーションのパターンなどをカスタマイズできる
  2. データメッセージ
    • key-valueを自由に設定できる
    • アプリ側で受信したメッセージを処理するコードを書く必要がある

移行する場合はどちらを使っていたのか確認する必要があります。ミラティブでは後者を使っていたので、ペイロードは変更することなく移行することができました。 (前者の場合対応するkey-valueを確認しなければなりません) なお、PerlからGoに移行するにあたり、ペイロードの抜け漏れがないことを同じ条件でのunit testを書いて確認しました。

https://firebase.google.com/docs/cloud-messaging/http-server-ref?hl=ja にレガシーなAPIの仕様が書いてあり、ミラティブではこの表の中で、

  • registration_ids
    • 一斉送信したいAndroid端末のトークン配列
  • data
    • key-valueを自由に設定できるデータ。これがデータメッセージに相当する

を使っていました。これをFCM HTTP v1の形式に改めて firebase.google.com/go/v4/messaging を使って書くとこのようになります。

コード例)

    package main

    import (
      firebase "firebase.google.com/go/v4"
      "firebase.google.com/go/v4/messaging"
    )

    opt := option.WithCredentialsJSON([]byte(appCtx.Secret("クレデンシャル情報を保存している環境変数名")))
    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        panic(err)
    }
    
    messagingClient, err := app.Messaging(ctx)
    if err != nil {
        panic(err)
    }
 
    messagingClient.SendEachForMulticast(ctx, &messaging.MulticastMessage{
        Tokens: tokens,
        Data:   map[string]string{
            "custom_key": "custom_value",
        },
    })

これで正常系については移行することができました。

ドキュメントのリトライポリシーに従ってエラーハンドリングを行う

エラーハンドリングの概要

上記の SendEachForMulticast() は以下のような構造体を返します。

type BatchResponse struct {
    SuccessCount int
    FailureCount int
    Responses    []*SendResponse
}

type SendResponse struct {
    Success   bool
    MessageID string
    Error     error
}

https://pkg.go.dev/firebase.google.com/go/messaging#BatchResponse

この Responses はそれぞれのトークンに対する結果をスライスで格納していて、その中の Success, Errorを見てエラーハンドリングをすることができます。 FCMが返しうるErrorの種類はこの表にまとめられており、messaging ライブラリにはこれらのErrorを判定する関数が用意されているので、それを使うとハンドリングしやすいです。

    for _, resp := range batchResp.Responses {
        if resp.Error != nil {
            case messaging.IsInvalidArgument(resp.Error):
                // 引数が不正
            case messaging.IsUnregistered(resp.Error):
                // トークンが無効
            case messaging.IsSenderIDMismatch(resp.Error):
                // 認証された送信者IDが、登録トークンの送信者IDと一致しない
            case messaging.IsQuotaExceeded(resp.Error):
                // 送信クォータを超えた
            case messaging.IsInternalServerError(resp.Error):
                // FCMサーバー内部エラー
            case messaging.IsUnavailable(resp.Error):
                // FCMサーバーの一時エラー
            default:
                // その他のエラー
            }
        }
    }

ミラティブではそれぞれのエラーに対して以下のように対応しています。

エラーの種類 対応
INVALID_ARGUMENT (400エラー) トークンを破棄する
UNREGISTERED (404エラー) トークンを破棄する
SENDER_ID_MISMATCH (401エラー) トークンを破棄する
QUOTA_EXCEEDED (429エラー) 下のリトライポリシーに従ってリトライする
INTERNAL (500エラー) 下のリトライポリシーに従ってリトライする
UNAVAILABLE (503エラー) 下のリトライポリシーに従ってリトライする
その他のエラー エラーログを出力する

リトライポリシー

公式のドキュメントから、リトライするべきエラーとその対応は以下のようにまとめられます。

エラーの種類 対応
QUOTA_EXCEEDED (429エラー) 最小遅延を1分に設定し、指数バックオフでリトライする
INTERNAL (500エラー) 最小遅延を10秒に設定し、指数バックオフでリトライする
UNAVAILABLE (503エラー) 最小遅延を10秒に設定し、指数バックオフでリトライする

これに加えて、FCMサーバーからのレスポンス内でretry-afterヘッダーによって再試行するべき時間が定められているならば、その時間だけ待ってからリトライするようにする必要があります。

ところが、messaging ライブラリを読むとUNAVAILABLEのみリトライ処理を行っていることがわかります。

func WithDefaultRetryConfig(hc *http.Client) *HTTPClient {
    twoMinutes := time.Duration(2) * time.Minute
    return &HTTPClient{
        Client: hc,
        RetryConfig: &RetryConfig{
            MaxRetries: 4,
            CheckForRetry: retryNetworkAndHTTPErrors(
                http.StatusServiceUnavailable,
            ),
            ExpBackoffFactor: 0.5,
            MaxDelay:         &twoMinutes,
        },
    }
}

https://github.com/firebase/firebase-admin-go/blob/6a28190d0d30fe0134648566c7e89f8635a51672/internal/http_client.go#L75-L88

この設定は上書きすることは現状できず、関連するissueも立っています。 Ability to override default retryconfig

しかもライブラリからretry-afterの情報は帰ってこないため、公式ドキュメントの推奨通りにリトライを実装できないという問題もあります。 No way to respect RetryAfter for retryable errors

そのため、ミラティブではこれらissueを静観しつつ、以下のように対応することとしました

エラーの種類 対応
QUOTA_EXCEEDED (429エラー) 最小遅延を1分固定で設定し、指数バックオフでリトライする。4回以上リトライはしない
INTERNAL (500エラー) 最小遅延を10秒固定で設定し、指数バックオフでリトライする。4回以上リトライはしない
UNAVAILABLE (503エラー) ライブラリのリトライに任せる

3% -> 10% -> 25% -> 50% -> 75% -> 100% と段階的にリリースする

この機能を一気にリリースするともし何か問題があった場合にその影響が大きいので、段階的にリリースすることにしました。具体的には、初めは全体のメッセージのうち3%だけ新しいAPIを使って送信し、問題がなかったので10%、25%、50%、75%、最後に100%と増やしていきました。問題がないか確認する上で、特に以下の点に気をつけました。

  • FCMから返ってくるエラーの割合が増えていないか
    • 特に無効なトークン数が増えていないか
  • 処理速度(レイテンシ、スループット)は問題ないか

現状ミラティブ運営がユーザーさんにプッシュ通知を一斉送信する際に若干レイテンシが伸びてしまうことを確認していますが、~300ms程度なので問題ないと判断しました。 以下は一斉送信時の処理時間の推移です。

(赤実線が処理にかかった平均時間(右軸の数値に対応し、単位はms)、青実線が処理したリクエスト数。リクエスト数が跳ねている部分が一斉送信を行った時刻)

今後さらに時間が伸びるならば、 一斉送信をもう少し時間的な幅を広げて行うなどの対応を検討しようと思います。

We are hiring!

ミラティブでは一緒に開発してくれるエンジニアを募集しています!

speakerdeck.com

mirrativ.notion.site

www.mirrativ.co.jp