📱

「再インストールでは直らない」スマホ機種変で起きたアプリ間共有認証データのデッドロック

に公開

新しいAndroidスマートフォンに機種変更したあと、特定のアプリだけが起動しなくなる事象に遭遇しました。AmazonショッピングとKindleが、真っ白・真っ黒な画面でしばらく固まったあと勝手に閉じる。再インストールしても、ストレージを削除しても、OSをアップデートしても直らない。一般的なサポート手順をひととおり試しても、状況は変わりませんでした。

最終的に、Amazon系アプリ群を全部一括でストレージ削除することで解決しました。原因をADBログから追ったところ、複数アプリで共有している認証データの整合性が壊れていて、起動時の認証取得処理がデッドロックしていることが分かりました。

事象自体はPixelとAmazonという特定の組み合わせで起きたのですが、構造としては「アプリ間で認証データを共有する設計」と「起動時に速度を狙って多数のサブシステムを並列初期化する設計」が噛み合うと、どのアプリでも起こりうるパターンだと言えます。SDK設計者、アプリ開発者、運用・サポート担当として知っておく価値があるので、ケーススタディとして残しておきます。

機種変で遭遇した事象

Pixel 10aにデータ移行した直後、特定のアプリだけが起動しなくなりました。

  • 該当: Amazonショッピング、Kindle
  • 症状: アプリを開くと真っ白または真っ黒の画面で十秒ほど固まり、勝手に閉じる
  • 該当しないアプリ: 他のアプリは正常に動く

公式に案内される対処は「再インストールしてください」「キャッシュを削除してください」「OSをアップデートしてください」といった一般的な手順が中心で、そのいずれを試しても直りませんでした。原因がアプリ単体ではなくOS・データ移行起因の問題だと、一般的なサポート経路だけでは切り分けが完結しにくい、という構造的な難しさもあります。

最初は「OSの問題か、アプリの問題か、Amazonアカウントの問題か」の切り分けすらできない状態でした。

何を試しても直らないとき、発想を変える

定番の対処はすべて試しました。

  • 該当アプリの再インストール
  • 該当アプリの単体ストレージ削除
  • 該当アプリのキャッシュ削除
  • Google Playストアの全アプリのアップデート
  • OSのアップデート
  • 端末の再起動

どれをやっても、該当アプリは起動しないままでした。

このときのポイントは「アプリ単体の問題」と思っているうちは直らない、ということです。該当アプリだけを再インストールしても、該当アプリだけのストレージを消しても、まったく改善しない。であれば、原因は「アプリ単体に閉じていない」可能性が高い。

「アプリ群の問題」だと発想を変えると、初めて解決策が見えてきます。

仮説: 共有認証データが壊れている

ここからは、まず公開情報だけで立てられる仮説を示します。実際にログで裏付けるのは後半です。

AmazonのLogin with Amazon SDKには、Amazonショッピングアプリのログイン状態を別のアプリから再利用する機構があります。これは公式ドキュメントに明記されている事実です。

https://developer.amazon.com/docs/login-with-amazon/customer-experience-android.html

ドキュメントによれば、ユーザーがすでにAmazonショッピングアプリにサインインしている場合、Login with Amazonを組み込んだアプリのログインではアカウント情報の再入力を求められず、SDKがAmazonショッピングアプリまたはFire OSデバイスの認証状態を認識して再利用する、と書かれています。いわゆるシングルサインオン(SSO)です。SDK内部のパッケージ名は com.amazon.identity.auth.map.device で、Amazon公式の移行ガイドにもこのパッケージ名が登場します。

https://developer.amazon.com/docs/login-with-amazon/upgrade-android-sdk.html

ここから言えるのは、Amazonの認証基盤(SDK内部でMAPと呼ばれる仕組み)が、Amazonショッピングアプリのログイン状態を別のアプリから参照できる設計になっている、ということです。公式ドキュメントが直接説明しているのはLogin with Amazonを使うアプリ向けのSSOですが、KindleやPrime VideoなどのAmazon純正アプリ群も同じ認証基盤を共有していると考えられます。

ここから考えられる仮説は、データ移行で認証データの一部だけが壊れた状態で引き継がれ、その共有データを取得しようとする処理が起動時に詰まっているのではないか、というものです。この仮説が正しければ、共有データを持つアプリを丸ごと初期化しないと直らない、という説明がつきます。

ただし「最初にインストールされたアプリが代表として認証情報を保持する」「特定のアプリだけが共有元になる」といった具体的な動作仕様は、公式情報からは断定できません。仮説の確からしさは、後半でANRトレースを読んで確かめます。

解決策: 関連アプリ群を一括でストレージ削除

先に解決策を書いておきます。具体的な手順は以下の通りです。

  1. 設定 > アプリ > XX個のアプリをすべて表示 を開く
  2. インストールされているAmazon系アプリをすべてリストアップする
  3. それぞれのアプリで ストレージとキャッシュ > ストレージを消去 を実行する
  4. 全部消し終わったら、端末を再起動する
  5. その後、AmazonショッピングやKindleを開いて、ログイン画面が出れば成功

対象になるアプリの例:

  • Amazonショッピング
  • Kindle
  • Amazon Prime Video
  • Amazon Music
  • Amazon Photos
  • Amazon Alexa

大事なのは、消すべき対象が「困っているアプリ」ではなく「認証データを共有しているアプリ群すべて」だという点です。今回のケースでは、最後に効いたのはPrime Videoのストレージ削除でした。普段ほとんど起動していないアプリで、これを消すまでは他のAmazon系アプリをいくら消しても直りませんでした。共有元になっていたのがこのアプリだった可能性があります。

機種変更時のデータ移行ツールは、旧端末のアプリ一覧から自動でアプリを復元します。その結果、最近では使っていないインストールしたことを忘れているAmazon系アプリが入っていることがあります。ユーザーの意識の中では「使っていないアプリ」でも、認証データの共有ネットワークの中では立派なノードで、そこにある破損データが起動中のアプリを巻き込みます。「困っているアプリだけ消す」「自分が使っているアプリだけ消す」という直感が、この事象では裏目に出ます。

ANRトレースで仮説を検証する

ここからが本題です。「本当に共有認証データのデッドロックなのか」をANR(Application Not Responding)のスタックトレースから検証します。

まず分かったのは、アプリを終わらせているのは「クラッシュ」ではなく「ANR」だということです。クラッシュなら例外が投げられて即座にプロセスが落ちますが、ANRはmainスレッドが一定時間(おおむね数秒から十秒)応答できなかったときにシステムが強制終了するものです。真っ白な画面で固まってから閉じる挙動は、例外で落ちるのではなく、応答待ちのまま時間切れになる典型的なANRの症状です。

自分の端末で、自分のアカウントで起きている事象なので、原因を追うためにPixelにMacからADBで接続し、OSがANR発生時に書き出した診断ログ(スタックトレース)を取得しました。なお、アプリを逆コンパイルしたわけではなく、OSが残した診断出力を読んでいるだけです。

adb shell dumpsys dropbox --print data_app_anr | \
  grep -A 200 "Process: com.amazon.mShop.android.shopping"

dumpsys dropboxの「DropBox」は、AndroidがクラッシュやANRなどの診断エントリを時系列で溜めておくシステムログ機構(DropBoxManager)のことです。--print data_app_anr で「アプリのANR」というタグのエントリだけを取り出し、Amazonショッピングのプロセス名で絞り込んでいます。

この結果、トレースには起動時に並列で走る複数のスレッドが記録されていました。重要なのは、これらが互いのロックを待ち合う状態になっていたことです。順に読み解きます。

main thread (tid=1): UIが止まっている本体

mainスレッドは AndroidComponentDetectTask という起動タスクを実行中に止まっていました。

"main" prio=5 tid=1 Blocked
  at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(...)
  - waiting to lock <0x00eb4d79> held by thread 37
  at com.amazon.platform.service.ServiceRegistryImpl.getService(...)
  at com.amazon.mShop.appStart.AndroidComponentDetectTask.apply(...)
  ...
  at android.app.ActivityThread.handleBindApplication(...)

<0x00eb4d79> というロックを取ろうとして、thread 37が解放するのを待っています。このロックは、Service Registry(アプリ内で各サブシステムが自身を登録・取得するための共通レジストリ)がサービスを取得・生成する際に内部で取るロックです。mainスレッドはAndroidではUIスレッドそのものなので、ここで止まると画面は何も描画されず、真っ白のままになります。

thread 36: 同じロックを待つ巻き添え

エラーレポートの初期化タスク(thread 36)も、mainとまったく同じロックを待っていました。

"StagedExecutor2-pool-19-thread-1" prio=5 tid=36 Blocked
  at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(...)
  - waiting to lock <0x00eb4d79> held by thread 37
  at com.amazon.platform.service.ServiceRegistryImpl.getService(...)
  at com.amazon.mShop.sam.log.SAMLogManager.initialize(...)
  at com.amazon.mShop.errorReporting.ErrorReporter.startSession(...)

これもmainと同じ <0x00eb4d79> の解放待ちです。Service Registryのこのロックは、起動時に複数のスレッドが奪い合う混雑ポイントになっています。

thread 37: ロックを抱えたまま認証データを待つ犯人

問題のスレッドがthread 37です。<0x00eb4d79>(Service Registryのロック)を保持したまま、別のロック <0x004a4835> を取ろうとして止まっていました。

"StagedExecutor3-pool-20-thread-1" prio=5 tid=37 Blocked
  - waiting to lock <0x004a4835> held by thread 62
  at com.amazon.identity.auth.device.api.MAPAccountManager.getAccount(...)
  at com.amazon.mShop.minerva.MinervaWrapperMAPClient.fetchAndSetAccountAttributeForTeen(...)
  at com.amazon.mShop.minerva.MinervaWrapperMAPClient.<init>(...)
  at com.amazon.mShop.minerva.MinervaWrapperServiceImpl.initializeMinervaClientIfNeeded(...)
  at com.amazon.platform.service.ServiceRegistryImpl.instantiateService(...)
  at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(...)
  - locked <0x00eb4d79>

下から上に読むと、処理は次の順序で進んでいます。

  1. Service Registryがサービスを生成しようとして、内部のロック <0x00eb4d79> を取得する(この瞬間、mainとthread 36は待たされる)
  2. そのロックを握ったまま、メトリクスSDK(Minerva)のクライアント初期化に進む
  3. その中で、現在ログイン中のアカウント情報を取りに MAPAccountManager.getAccount を呼ぶ
  4. 認証SDK(MAP)の内部で別のロック <0x004a4835> を取ろうとする
  5. しかしそのロックはthread 62が保持中で、いつまでも返ってこない

thread 37は「Service Registryのロックを握ったまま、認証データの取得待ちで固まる」という、デッドロックを引き起こす決定的な位置にいます。握っているロックを離さないので、それを待つmainとthread 36も連鎖的に止まります。

thread 27: 認証ロックを待つもう一人

さらにthread 27(Weblab、A/Bテストのフラグ取得)も、thread 37と同じ認証ロック <0x004a4835> を待っていました。

"StagedExecutor1-pool-15-thread-1" prio=5 tid=27 Blocked
  - waiting to lock <0x004a4835> held by thread 62
  at com.amazon.identity.auth.device.api.MultipleAccountManager.getAccountForMapping(...)
  at com.amazon.mShop.sso.SSOUtil.getCurrentAccountFromDisk(...)
  at com.amazon.mShop.core.features.weblab.WeblabServiceImpl.getTreatmentAndCacheForAppStartWithTrigger(...)

Weblabも起動時に認証情報を必要としていて、getAccountForMapping で同じ認証ロックの解放を待っています。

全体像: 認証データの取得が全タスクの合流点になっている

整理すると次の依存関係になります。

注目したいのは、起動時に並列で走るはずだったタスク(メトリクス、エラーレポート、A/Bテスト、コンポーネント検出)が、最終的にすべて「MAPの認証データ取得」という一点に合流していることです。Minervaも、Weblabも、それぞれ独立した機能のはずなのに、初期化のどこかで「今ログインしているのは誰か」を知ろうとして同じ認証SDKを叩きます。

その認証データの取得が、共有データの破損で永久に返ってこない。すると認証を必要とする全タスクが止まり、Service Registryのロックを抱えたタスクが止まることで、認証と無関係なタスク(main、エラーレポート)まで巻き添えになる。これが、画面が真っ白のまま固まってANRに至る連鎖の全体像です。

認証ロックを保持したまま止まっているthread 62をたどると、ContentProvider経由で別プロセスに問い合わせを投げ、その応答を待っている状態でした。ContentProviderはAndroidでアプリ間のデータ共有に使う仕組みで、Amazon系アプリはこれで認証データを融通し合っているとみられます。共有元のどれかが応答を返さないために、thread 62が認証ロックを抱えたまま止まっていたと考えられます。どのアプリが、なぜ応答しなかったのかまではこのトレースからは特定できません。ただ「共有元の認証データを取りに行って返ってこない」という構造は、Amazon系の全アプリのストレージを消すと直った事実と整合します。

なお厳密に言えば、これは二つのスレッドがお互いのロックを取り合う循環待ち(古典的なデッドロック)ではありません。ロックを抱えたスレッドが外部プロセスの応答待ちで固まり、それを待つ側が連鎖的に止まる、というハングです。ただ「ロックを保持したまま動けず、待っている全員が永遠に解放されない」という結果はデッドロックと変わらないので、本記事ではデッドロックと呼んでいます。

この事例が示す、設計上の落とし穴

教科書的な「ロックは順序を揃えて取る」「mainをブロックするな」という教訓は、もちろんこの事例にも当てはまります。ただ、それ以上に今回のトレースが浮かび上がらせたのは、よかれと思った設計判断が積み重なったときの落とし穴です。速度のための並列化、機能のための認証参照、利便性のためのアプリ間データ共有。どれも単体では妥当ですが、重なると次の三つの落とし穴になります。

落とし穴1: 並列初期化の高速化が、共有リソースで裏目に出る

起動を速くするために、サブシステムを並列で初期化する。これは正しい最適化に見えます。実際、今回のトレースにも、メトリクス、エラーレポート、A/Bテスト、コンポーネント検出など、複数の初期化タスクが別スレッドで並行して走っている様子が記録されていました。

ところが、それらの多くが内部で「Service Registryへの登録」と「現在のログインアカウントの取得」という共通処理を呼びます。並列で走らせても、共有リソースのロックで結局直列化する。それだけなら起動が遅くなるだけですが、ロックを保持したスレッドが別の何かで止まると、今回のように待っていた側がまとめて巻き込まれます。

速度を狙った並列化が、共有リソースの競合で実質直列になり、最悪の場合デッドロックします。これは「並列化したから速くなったはず」という思い込みが一番危ない場所です。起動タスクを増やすときは、それぞれが共有リソース(レジストリ、認証、設定ストア)をどう触るかをセットで見ないと、台数効果が出ないどころかデッドロックの確率が上がります。

落とし穴2: 認証が全機能の暗黙の依存になっている

トレースで一番意外だったのは、メトリクスもA/Bテストも、機能としては認証と無関係に見えるのに、初期化時に「今ログインしているのは誰か」を取りに行っていたことです。メトリクスはユーザー属性を付けたいから、A/Bテストはアカウント単位で振り分けたいから。理由はそれぞれもっともですが、結果として認証SDKがアプリ全体の暗黙の依存点になっています。

認証データの取得が一点で詰まると、認証そのものではなく、認証を参照していた全機能が連鎖的に止まります。認証は「ログイン画面まわりの関心事」ではなく「起動シーケンス全体のクリティカルパス」になっている、という認識が要ります。自身のアプリで、認証情報の取得が起動時にいくつのサブシステムから呼ばれているかを数えてみると、想像より多いかもしれません。

落とし穴3: 共有データの所有権が、データ移行で宙に浮く

複数アプリで認証データを共有する設計は、ユーザーにとっては便利です。一度サインインすれば他のアプリでログインが不要になります。問題は、その共有データを「誰が所有し、壊れたら誰が直すのか」が暗黙になっている点です。

仮に「最初にインストールされたアプリが代表」のような暗黙のルールがあるとすると、データ移行でインストール順序や状態が再現されないと、所有関係が宙に浮きます。所有者が壊れたデータを抱えたまま、他のアプリがそれを参照しに行くことになります。今回、Prime Videoを消すまで直らなかったのも、こうした所有権の曖昧さが背景にあるのかもしれません。共有データには、所有者が消えたり壊れたりしたときに別アプリが引き継ぐ、または安全に再生成するフォールバックがあると安心でしょう。

サポート対応・ユーザー視点での教訓

設計を変えられない立場でも、この構造を知っているかどうかで対応速度が変わります。

サポート対応では、「再インストールしてください」が効くのはアプリ単体に閉じた問題のときだけだ、と意識しておくことです。機種変更直後の「特定アプリだけ起動しない」という問い合わせには、データ移行起因で共有データが壊れている可能性を疑い、「関連アプリを含めて一括でストレージ削除」という次の一手を案内できると、初動が変わります。「機種変更直後ですか」と最初に一言聞くだけでも、調査の方向がぐっと絞れることもあります。

ユーザーとしては、「困っているアプリ」ではなく「同じ提供元のアプリ群すべて」を初期化対象として考えること。使っていないアプリも共有ネットワークのノードであり、そこの破損が巻き添えを起こすという発想を持っておくと、自力で抜け出せる確率が上がります。

同じパターンが起こりうる他のケース

Amazonに限らず、複数アプリで認証情報を共有する設計はいくつもあります。

  • Android標準の AccountManager を使った認証トークンのアプリ間共有
  • 同一署名鍵のアプリ間で ContentProvider を介してログイン情報を共有する設計
  • 共通アカウント基盤を持つアプリ群(同じ会社の複数アプリをまたいだログイン連携など)

これらが「起動時に多数のサブシステムを並列初期化する」設計と組み合わさると、今回と同じ条件が揃います。共有データが壊れたときに全アプリが連鎖して起動不能になり、単体の再インストールでは直らない。自社のアプリ群がこの二つの条件に当てはまるなら、機種変更時の整合性をどう保証するか、共有元が壊れたときにどう縮退・再生成するかを、一度確認しておく価値があるでしょう。

おわりに

機種変更後にアプリが起動しないという事象に遭遇したら、まず「単体クリアで直らないなら関連アプリ群を一括クリアする」を試す。これがユーザー視点での最短解決策です。共有元が壊れたら、共有元ごと消さないと直りません。

設計する立場では、今回のトレースが示した三つの落とし穴を覚えておくと役に立ちます。並列初期化は共有リソースで裏目に出ること、認証が全機能の暗黙のクリティカルパスになりやすいこと、共有データの所有権がデータ移行で宙に浮くこと。どれも単体では「よかれと思った設計」なのに、組み合わさると起動不能なアプリを生みます。

「再インストールしてください」が万能解として通用するのは、アプリ単体に閉じた設計の場合だけです。共有データを持ち、複雑な起動シーケンスを抱えたアプリでは、再インストール後も同じ症状が再発します。この構造を一つ知っているだけで、同じ事故に出くわしたときの初動は大きく変わります。

GENDA

Discussion