モバイルアプリがユーザーのアップデート遅延で壊れるとき

新しいバックエンドエンドポイントをプッシュした。最新のアプリバージョンでは完璧に動作する。CI/CDパイプラインもすべてグリーン。ところが、クラッシュレポートが届き始める。

古いアプリバージョンのユーザーが同じエンドポイントにアクセスし、新しいレスポンス形式をパースできずに、真っ白な画面、クラッシュ、あるいはさらに悪いことに、静かにデータが失われている。バックエンドを全ユーザー向けに変更したわけではない。最新のアプリバージョンのためだけに変更した。しかし、バックエンドは区別しない。リクエストしてきたすべてのクライアントに同じレスポンスを返す。

これがモバイルデリバリーの隠れたコストだ。クライアントを制御できるWebアプリとは異なり、モバイルアプリは自分が所有していないデバイス上で動作する。アップデートするかどうかを決めるのはユーザーだ。すぐにアップデートする人もいれば、数週間待つ人もいる。6ヶ月前にリリースしたバージョンをまだ使っている人もいる。その間、バックエンドは進化し続ける。

バージョンギャップ問題

根本的な緊張関係は単純だ。バックエンドは継続的に変更されるが、モバイルクライアントは独自のスケジュールでアップデートされる。新しいエンドポイントを追加するたびに、レスポンス構造を変更するたびに、フィールドを非推奨にするたびに、古いアプリバージョンにとっての潜在的な破壊ポイントが生まれる。

以下のシーケンス図は、バックエンドの変更が新しいバージョンでは問題なく動作する一方で、古いアプリバージョンをどのように壊すかを示している。

sequenceDiagram participant AppV1 as App v1.0 participant AppV2 as App v2.0 participant Backend as Backend Note over Backend: Backend evolves AppV1->>Backend: GET /api/orders (old format) Backend-->>AppV1: 200 OK (old response) Note over AppV1: Works fine AppV2->>Backend: GET /api/v2/orders (new format) Backend-->>AppV2: 200 OK (new response) Note over AppV2: Works fine AppV1->>Backend: GET /api/v2/orders (new format) Backend-->>AppV1: 200 OK (new response) Note over AppV1: CRASH - can't parse new format Note over Backend: Solution: keep old endpoint for backward compatibility

ほとんどのチームは、本番環境で壊れるまでこれに気づかない。最新のアプリを最新のバックエンドでテストし、すべてパスしてリリースする。しかし、テストスイートが古いアプリを新しいバックエンドに対して実行することは決してない。その組み合わせは、実際のユーザーが遭遇するまで見えない。

ユーザーベースが大きくなるにつれて、問題は悪化する。ユーザーが増えれば、世の中に出回っているバージョンも増える。各バージョンは、バックエンドが返すべきものについて独自の期待を持っている。バックエンドは、少なくともしばらくの間、それらすべてを同時に満たす必要がある。

どのバージョンが出回っているかを把握する

互換性を管理する前に、可視性が必要だ。アプリストアはある程度のデータを提供する。Google Play ConsoleとApp Store Connectはどちらもバージョン分布を示す。しかし、そのデータは遅延があり、集計されている。ユーザーが何をインストールしたかはわかるが、何をアクティブに使っているかはわからない。

より良いアプローチ:すべてのリクエストにアプリバージョンを含める。X-App-Versionのようなカスタムヘッダーを追加するか、User-Agent文字列にエンコードする。バックエンドはこの情報をログに記録し、リアルタイムのバージョン採用状況を示すダッシュボードに集計できる。

このデータは重要な質問に答える。

  • アクティブユーザーの何パーセントが各バージョンを使っているか?
  • ユーザーは最新リリースをどのくらいの速さで採用しているか?
  • どの古いバージョンがまだ重要なトラフィックを持っているか?
  • いつレガシーバージョンのサポートを安全に終了できるか?

このデータがなければ、盲目的に決定を下すことになる。まだ30%のアクティブユーザーがいるバージョンを非推奨にするかもしれないし、たった2%しか使っていないバージョンをサポートし続けるかもしれない。

バックエンドの互換性を維持する

標準的なアプローチは後方互換性だ。エンドポイントを変更するときは、古いレスポンス形式をすぐに削除しない。新しいフィールドを古いものと一緒に追加するか、APIエンドポイントを明示的にバージョニングする。

例えば、/api/ordersを変更する代わりに、/api/v2/ordersを作成し、/api/v1/ordersを稼働させ続ける。最新のアプリはv2と通信し、古いアプリはv1を使い続ける。これにより、ユーザーは体験を損なうことなくアップグレードする時間を得られる。

しかし、後方互換性には限界がある。すべてのエンドポイントの5つのバージョンを永遠に維持することはできない。サポートするバージョンが増えるごとにコストは増大する。ある時点で、古いバージョンを切り離す必要がある。

ここでバージョンモニタリングが不可欠になる。データが、レガシーバージョンが許容可能なしきい値(例えばアクティブユーザーの5%)を下回ったことを示したら、非推奨を発表できる。アプリ内通知でユーザーにアップデートを促す。期限を設定する。その日以降、古いエンドポイントは動作しなくなる。

必要に応じて強制アップデートを行う

段階的な採用を待てない場合もある。セキュリティパッチ、クリティカルなバグ修正、規制変更など、即時のアップデートが必要な場合だ。そのような場合、ユーザーにアップグレードを強制するメカニズムが必要になる。

パターンは単純だ。バックエンドは各リクエストのX-App-Versionヘッダーをチェックする。バージョンが最小しきい値を下回っている場合、バックエンドは特別なレスポンスコードまたはペイロードを返す。アプリはこれを検出し、必須のアップデート画面を表示する。ユーザーはストアから最新バージョンをダウンロードするまで先に進めない。

これは鈍器のような手段だ。控えめに使うこと。強制アップデートは毎回摩擦を生み出し、否定的なレビューのリスクを高める。しかし、必要なときには、脆弱または壊れたバージョンを使い続けさせるよりはましだ。

リモートコンフィグをセーフティネットとして使う

リモートコンフィグは、完全な互換性と強制アップデートの中間的な手段を提供する。コードを変更する代わりに、サーバーから設定を変更する。アプリは定期的にこの設定を取得する。URL、タイムアウト、フィーチャートグル、エンドポイントバージョンなど、ストアのアップデートを必要としない。

互換性問題にどのように役立つかを説明する。最新のアプリバージョンに、特定のバックエンドエンドポイントでのみ現れるバグがあるとする。ストアのレビューに数日かかるため、アプリをすぐに修正できない。しかし、リモートコンフィグを変更して、そのアプリバージョンを古くて安定したエンドポイントに向けることができる。コードを1行も変更せずにバグが消える。

リモートコンフィグは段階的なロールアウト中にも役立つ。バージョン4.2のユーザーが新機能でクラッシュしていることに気づいたら、バージョン4.2に対してのみその機能を無効にし、バージョン4.3では有効にしておくことができる。設定はバージョン認識型なので、各アプリバージョンは処理可能な動作を得られる。

バージョン認識型のリモートコンフィグペイロードの例は次のようになる。

{
  "config": {
    "new_checkout_flow": {
      "enabled": true,
      "disabled_versions": ["4.2.0", "4.2.1"]
    },
    "api_base_url": "https://api.example.com/v2",
    "legacy_api_base_url": "https://api.example.com/v1",
    "timeout_ms": 10000
  },
  "flags": {
    "dark_mode": true,
    "experimental_search": false
  }
}

アプリはdisabled_versionsリストを読み取り、バージョン4.2.0と4.2.1では新しいチェックアウトフローをスキップし、古いフローにフォールバックする。アプリのアップデートは不要だ。

緊急復旧のためのフィーチャーフラグ

フィーチャーフラグも同様に機能するが、設定値ではなく機能のオン/オフに焦点を当てている。新機能が本番環境で問題を引き起こした場合、ダッシュボードからフラグをオフにする。機能はアプリから消える。ユーザーはそれを見ず、クラッシュせず、苦情も言わない。

リモートコンフィグに対する利点は粒度だ。特定のユーザーセグメント、地域、アプリバージョンをターゲットにできる。段階的にロールアウトできる。A/Bテストもできる。そして問題が発生した場合、新しいリリースなしで即座に機能を停止できる。

フィーチャーフラグは、デプロイとリリースを切り離すため、モバイルにとって特に価値がある。フラグの背後に隠されたコードを出荷し、準備ができたらアクティブにし、状況が悪化したら非アクティブにできる。アプリを変更する必要はない。フラグサーバーがすべてを処理する。

バージョン管理のための実践的チェックリスト

  • カスタムヘッダーを介してすべてのリクエストにアプリバージョンを含める
  • リアルタイムのバージョン分布を示すダッシュボードを構築する
  • レガシーバージョンの使用率が5%を下回るまで、古いAPIエンドポイントを稼働させ続ける
  • 最小サポートバージョンを設定し、強制アップデートメカニズムでそれを強制する
  • アプリのアップデートなしでサーバー側の動作変更を行うためのリモートコンフィグを実装する
  • 問題のある機能を即座に無効にするためにフィーチャーフラグを使用する
  • サポートを終了する前に、アプリ内通知で非推奨の期限を発表する

まとめ

モバイルデリバリーは、ビルドが署名されストアにアップロードされた時点で終了しない。すべてのユーザーが現在のバックエンドで動作するバージョンを使うようになった時点で終了する。それには数週間から数ヶ月かかる可能性がある。その間、バックエンドは変更され、すべての変更は古いバージョンを実行している誰かにとって潜在的な破壊となる。

バージョン分布を監視せよ。実用的である限り、後方互換性を維持せよ。問題が発生した場合に時間を稼ぐために、リモートコンフィグとフィーチャーフラグを使用せよ。そしてアップデートを強制しなければならない場合は、反射的ではなく、意図的に行え。

目標はバージョンの断片化を排除することではない。それは不可能だ。目標は、何が出回っているかを把握し、それを動作させ続け、動作しなくなった場合の計画を持つことだ。