DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

iOSのOOMクラッシュをみつける

こんにちは、SWETグループ所属のkariadです。

昨年10月に開催されたiOS Test OnlineにてSWETチームのkuniwakが「実践9つのメモリリークどう見つける?」というタイトルで発表しました。 その発表では触れられなかった、メモリリークから引き起こされるOOMクラッシュを発見する手法についてSWETで実践したことを紹介します。

メモリリークについての説明は多くの記事で説明されているため、省略します。

OOMクラッシュ

メモリリークが発生するとOOMクラッシュの危険性があります。 OOMとはOut Of Memoryの略であり、アプリが確保しているヒープ領域を超えてメモリを利用しようとした際に、OSからアプリがキルされクラッシュしてしまいます。 通常のクラッシュにおいては大半のアプリで導入されているであろうFirebase Crashlyticsにて検知可能です。 一方でOOMクラッシュはCrashlyticsで検知できません。 これはFirebase Crashlyticsで検知できるものが、Swiftのランタイムエラー、NSExceptionに限られているためです。 OOMクラッシュはこれらとは異なりOSからの命令でアプリケーションが強制終了されるため、Crashlyticsで検知できません。

現時点でFirebase CrashlyticsやAppleから提供されているツールにおいて、OOMクラッシュを即座に検知し教えてくれるものはありません。(その他3rdツールで可能なものももしかしたらあるかもしれないですが) そうした状況において可能な限り、OOMクラッシュを発見したい、検知したいと思ったときに取れる方法をiOS Test Onlineで紹介しきれなかったものとして2つをタイミング別に説明していきます。

本番環境での検知

OOMクラッシュの即時検知は難しいという話をさきほどしました。 しかし、MetricKitを用いることで発生回数は取得可能です。

MXAppExitMetricのForeground,Backgroundそれぞれのクラス内にプロパティとしてcumulativeMemoryResourceLimitExitCountが定義されています。 こちらは説明をそのまま読むと、「システムがフォアグラウンドからメモリ使用量が多すぎるためにアプリを終了させた回数」とあります。 厳密にはOOMクラッシュの回数とイコールにはなりませんが、継続的に記録していけばアプリのバージョン毎に比較して新たなバージョンで極端に増えた場合はメモリリークの発生を疑ってもいいでしょう。 発生が検知できたら、iOS Test Onlineで紹介されたツール群を使い、具体的な原因の特定につなげていくことをお勧めします。

検証中などに不意に発生したOOMクラッシュや特殊環境でのOOMクラッシュの特定

続いては開発中や検証中にクラッシュしたが、Crashlyticsにログも出力されないという場合や、Crashlyticsが利用できない環境(実際に私が直面したのはこちらでした)などに、端末のログからOOMが原因でクラッシュしたかどうかを判別する方法を紹介します。 端末でメモリ不足となった場合にシステムからアプリが終了させられ、メモリが解放されることをJetsamイベントと言います。 Jetsamイベントが発生すると、端末にJetsamEventReportというログが出力されます。 このJetsamイベントレポートには端末の各プロセスがどの程度メモリを使用していたか、解放された原因がなにであるか記載されています。

では具体的にJetsamEventReportを見ていきます。

JetsamEventReportはクラッシュのログ等と同様に、「プライバシーとセキュリティ」 > 「解析と改善」 > 「解析データ」から見ることができます。

{
  "crashReporterKey" : "2de183711d66c974754eca20731a63a9ace8490f",
  "kernel" : "Darwin Kernel Version 21.4.0: Mon Feb 21 21:26:14 PST 2022; root:xnu-8020.102.3~1\/RELEASE_ARM64_T8020",
  "product" : "iPad8,1",
  "incident" : "93704C46-9EC0-4497-96D9-003069B18B71",
  "date" : "2022-05-24 08:13:28.88 +0900",
  "build" : "iPhone OS 15.4.1 (19E258)",
  "timeDelta" : 81,
  "memoryStatus" : {
  "compressorSize" : 17626,
  "compressions" : 258335,
  "decompressions" : 49705,
  "zoneMapCap" : 1443561472,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41271296,
  "pageSize" : 16384,
  "uncompressed" : 198018,
  "zoneMapSize" : 146276352,
  "memoryPages" : {
    "active" : 94122,
    "throttled" : 0,
    "fileBacked" : 92968,
    "wired" : 34174,
    "anonymous" : 94049,
    "purgeable" : 28,
    "inactive" : 92012,
    "free" : 3679,
    "speculative" : 883
  }
},
  "largestProcess" : "SignalPlayground",

サンプルとなるJetsamイベントレポートの先頭部分です。 ここには端末の情報が主に記録されています。 アプリが、メモリリークが原因で強制終了してしまったかどうかを調べるために、注目するのは先頭部分が終わった直後に記録されているlargestProcessです。 largestProcessではもっともメモリを使用しているプロセス名が記録されます。 調査したいアプリのメモリリークが原因で強制終了した場合はlargestProcessにアプリ名が入ってくることが高いです。 今回の例でいうとSignalPlaygroundというプロセスが記録されています。 SignalPlaygroundはメモリリークを意図的に発生させるために作成したアプリです。

SignalPlaygroundでJetsamEventReport内を検索すると次のようなブロックが見つかります。

{
    "uuid" : "bffca555-98df-3618-a95e-e722ad0f0066",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 30379,
    "genCount" : 0,
    "purgeable" : 0,
    "age" : 164612203,
    "fds" : 25,
    "coalition" : 699,
    "rpages" : 183494,
    "priority" : 10,
    "reason" : "per-process-limit",
    "physicalPages" : {
      "internal" : [
        46477,
        136905
      ]
    },
    "freeze_skip_reason:" : "none",
    "pid" : 2530,
    "cpuTime" : 0.79734499999999997,
    "name" : "SignalPlayground",
    "lifetimeMax" : 183494
  },

ここで注目するポイントは、reasonです。 reasonにこのプロセスが終了するに至った原因が記録されています。 per-process-limitがここでは記録されています。 per-process-limitはシステムが設定したアプリ毎のメモリ制限を超えた場合に記録されます。 このメモリ制限を超えた場合にシステムからアプリが、強制終了させられることがあります。 メモリリークが疑われる状況において、JetsamEventReportにてアプリの終了原因としてper-process-limitが記録されていた場合、メモリリークが原因による強制終了と見ていいでしょう。

また、rpagesの値から実際にどの程度メモリを利用していたかを計算できます。 JetsamEventReportの先頭部分に記録されている、pageSizeの値とrpagesの値を乗算することで実際のメモリ使用量を求められます。 今回の例ではpageSizeが16384、rpagesが183494のため、3006365696(Byte)となり約3GBにもなります。 さらにuuidでどのビルドかなどを絞り込むことはできますが、詳細な原因を特定まではできません。 JetsamEventReportで分かることは、あくまでもシステムから強制終了させられたかどうかとその理由、そしてどの程度メモリを使用していたか、になります。

再現可能なメモリリークであれば、Instrumentsを使い調査できますが、突然発生してしまった場合などよくわからない場合にJetsamEventReportを活用できます。 JetsamEventReportについては次のドキュメントを参考にしています。もっと知りたい、という方は参考にしてみてください。 Identifying high-memory use with jetsam event reports

今回紹介した方法はいずれも単独で原因をみつけられるものではなく、手がかりのひとつとして利用可能な手段です。 iOS Test Onlineで発表された手法も合わせ、状況に応じていくつかある手段を用いてメモリリークに対処していくことが大切になります。