Lambda@Edge で画像のリアルタイム変換を試してみた

技術部の久須 (@) です。クックパッドではモバイル基盤グループにて Android 版クックパッドアプリの開発・メンテナンスに携わっています。

今回は普段の業務とは少し異なるのですが、個人的に興味があった AWS の Lambda@Edge でリアルタイムに画像を変換する仕組みについて試してみたので、構築した環境の内容やコードをここで紹介したいと思います。

注意:この仕組みで実運用している訳ではなく調査用の AWS 環境で動かしている段階なので、もし参考にされる場合はご注意ください。ちなみにクックパッドにはこのような本番環境とは切り離された調査、検証用の AWS 環境があり、エンジニアは自由に AWS の各種コンポーネントを試すことができます。

概要

Lambda@Edge とは、公式ドキュメント にも説明がありますが CDN である CloudFront の入出力 HTTP リクエスト・レスポンスを操作できる Lambda 関数です。今回は CloudFront のオリジンとして S3 を指定し、S3 からの画像レスポンスを Lambda@Edge で変換する仕組みを構築しました。

f:id:hkusu:20180524153623p:plain

この仕組みでは画像へのリクエストに応じてその場で画像変換を行うので、サービスの運営において様々なバリエーションの画像が必要な場合であってもそれらを予め用意しておく必要がなく、画像を変換する為のサーバも必要としません。S3 に画像ファイルさえ置けばよいのでサーバサイドのアプリケーションの種類や言語を問わず、たとえ静的な WEB サイトであったとしても様々なバリエーションの画像を提供することができます。

変換後の画像は CloudFront にキャッシュされるので、変換処理が行われるのは CloudFront にキャッシュがない場合のみです。Amazon Web Services ブログ では変換後の画像を S3 に保存する方法が紹介されていますが、今回の方法では変換後の画像は CloudFront のみに持つ構成としています。そもそも CloudFront のキャッシュ期間を長く設定しておけばよいという話もありますが、たとえ CloudFront のキャッシュがきれた場合でも画像変換を再度実行するのではなく CloudFront のキャッシュの保持期間を延長することで変換コストを抑えることができます(この方法については後述します)。また変換後の画像をキャッシュでしか保持していないので、後から画像変換の仕様が変わったり不具合があったりしたとしても S3 上の画像を消す等のオペレーションを必要とせず、CloudFront 上のキャッシュを消す(CloudFront に invalidation リクエストを送る)だけで対応できます。

Lambda@Edge を利用する上での注意点

まず Lambda@Edge 用の関数の開発で利用できる言語は Node.js かつバージョンは 6.10 のみです。旧来の Lambda 関数の開発で利用できる 8系 は現時点で対応していません。また Lambda@Edge 用の関数を作成できるのは米国東部(バージニア北部)リージョンのみです(ただし作成した関数は各 CloudFront のエッジへレプリケートされます)。

そのほか制限は公式ドキュメントの Lambda@Edgeの制限 のとおりです。注意すべきは Lambda@Edge でオリジンレスポンス(今回の構成では S3 からの画像レスポンス)を操作する場合、操作後のレスポンスのサイズはヘッダー等を含めて 1MB に抑える必要があることですが、通常のWEBサイトやモバイルアプリでの用途としては十分な気がします。タイムアウトまでの制限時間は 30 秒と長く、メモリも最大 3GB ほど使えることから画像を扱う環境としては問題なさそうです。

また、これは Lambda@Edge 用の関数の実装時の制約なのですが、関数からオリジンレスポンスを取り扱う際、関数からはレスポンスBody(画像データ)にアクセスできません。よって、改めて関数から S3 へアクセスし画像ファイルを取得する必要があります。

環境の構築 (CloudFront、S3)

特筆すべきことはなく S3 のバケットを作成し、それをオリジンとして CloudFront を設定すれば問題ありません。ただし、クエリ文字列はフォワード&キャッシュのキーに含める(クエリ文字列が異なれば別ファイルとしてキャッシュする)ようにしてください。これは後述しますがクエリ文字列で画像の変換オプションを指定する為です。また CloudFront のキャッシュ期間も適宜、設定しておきます(動作確認中は1分など短くしておくとよいです)。

f:id:hkusu:20180524154845p:plain

環境の構築 (Lambda@Edge)

Lambda@Edge の環境構築については 公式ドキュメント に詳しいでここではポイントのみ述べます。

ロールの作成

AWS の IAM にて予め Lambda@Edge 用の関数の実行ロールを作成しておきます。

アタッチするポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::*"
            ]
        }
    ]
}
  • S3 の画像を参照する権限を追加

信頼関係

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "lambda.amazonaws.com",
          "edgelambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  • edgelambda.amazonaws.com を追加

関数の作成

Lambda@Edge 用の関数の実装を用意する前に、管理コンソール上で関数の枠組みだけ作成しておきます。米国東部(バージニア北部)リージョンの Lambda のメニューから関数を作成します。

f:id:hkusu:20180524154855p:plain

[関数の作成] ボタンを押下し関数を作成します。選択した実行ロールの情報を元に、本関数からアクセスが可能な AWS のリソースが次のように表示されます。

f:id:hkusu:20180524154933p:plain

問題なければ一旦、枠組みの作成は完了です。

関数の実装の用意

実装例として、サンプルコードを私の方で作成しました。GitHub に置いてあるので参考にしてください。
hkusu/lambda-edge-image-convert

サンプルコードの説明

画像のリサイズと WebP 形式への変換の機能を提供します。仕様は次のとおりです。

  • 変換元の画像は JPEG 形式の画像のみ

  • リサイズの際に画像のアスペクト比(横幅と縦幅の比率)は変更しない

  • 変換オプションはクエリ文字列で指定する

    キー デフォルト 最大値 補足
    w   最大横幅(ピクセル)を指定 1200    1200   変換元の画像より大きな値は無効
    (=拡大しない)
    h 最大縦幅(ピクセル)を指定 同上 同上 同上
    p t (true):WebP 形式へ変換する
    f (false):WebP 形式へ変換しない
    f (false) - -
    • https://xxx.com/sample.jpg?w=500&p=t
    • w h で「最大」としているのは最終的に適用される値はアスペクト比を維持しながら決定される為
  • 変換後の画像品質(quality)は一律で変換元画像の 80% とする

  • 変換後の画像のメタデータは全て削除する(意図せず位置情報等が露出するのを防ぐ為)

この仕様だとメインロジックは index.js 1ファイルに収まりました。メインロジックを少し補足をします。

l.7〜l.12

let sharp;
if (process.env.NODE_ENV === 'local') {
  sharp = require('sharp');
} else {
  sharp = require('../lib/sharp');
}

画像変換には sharp というライブラリを利用しています。このライブラリのランタイムは実行環境により異なる為、ローカルでは開発環境の構築時にインストールされた node_modules/sharp を利用し、AWS 上で Lambda 関数として実行する際は lib/sharp ディレクトリのものを利用するようにしています。

サンプルコードのリポジトリには lib/sharp ディレクトリは含まれていません。AWS 上で Lambda 関数として動かすには、EC2 等を構築して Amazon Linux 上で $ npm install sharp を実行し、生成された node_modules/sharp ディレクトリを中身ごと lib ディレクトリ配下へ配置してください。

l.18〜l.19

exports.handler = (event, context, callback) => {
  const { request, response } = event.Records[0].cf;

関数への入力として渡される event オブジェクトから request オブジェクト、response オブジェクトを取り出しています。

l.36〜l.39

if (response.status === '304') {
  responseOriginal();
  return;
}

CloudFront 上のキャッシュがきれた場合、S3 に対して ETag 付きの条件つきリクエストを送ってきます。S3 からは 304 コードが返ってくるので、この場合は何もせずレスポンスをスルーして終了します。CloudFront が 304 レスポンスを受け取った場合、キャッシュの破棄ではなくキャッシュの保持期間の延長が行われます。

l.77〜l.82

s3.getObject(
  {
    Bucket: BUCKET,
    Key: options.filePath.substr(1), // 先頭の'/'を削除
  })
  .promise()

非同期の処理を行うにあたりコールバックのネストが深くなってしまうので、可読性の向上を目的に Promise インタフェースを利用しています。

l.85

return sharpBody.metadata();

変換前の画像のメタデータを取得しています。ただし取得は非同期です。

l.95

sharpBody.resize(options.width, options.height).max();

画像のリサイズを行っています。ここで .max() を指定することにより画像のアスペクト比が維持されます。

l.99〜l.101

return sharpBody
  .rotate()
  .toBuffer();

sharp では .withMetadata() を指定しない限り、変換後の画像のメタデータは全て削除されます。この際、画像の orientation(向き) の情報も削除されてしまう為、変換後の画像をブラウザ等で表示すると画像の向きが反映されていません。 .rotate() を指定すると、画像の向きが合うよう画像データそのものが回転されます。

また、今回 .quality() での画像品質の指定は行っていないので、デフォルトの 80 が適用されます。

l.104〜l.112

response.status = '200';
if (options.webp) {
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/webp' }];
} else {
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/jpeg' }];
}
response.body = buffer.toString('base64');
response.bodyEncoding = 'base64';
callback(null, response);

画像をレスポンスするコードです。 Content-Length ヘッダはここで設定しなくても AWS 側で自動で付与されます。

今回、S3 からのレスポンス(response変数)をそのまま利用し必要な箇所だけ上書きしている為、ETag Last-Modified ヘッダはここで再設定しない限り S3 から返されたものがそのまま CloudFront に渡ります。変換オプション毎に変換後の画像データは異なる為、ETag Last-Modified ヘッダも変換オプション毎に変更した方が良いと考えるかもしれません。ただ CloudFront でクエリ文字列込の URL ベースでキャッシュするようにしている場合は、ETag Last-Modified ヘッダは共通で問題ありません。変換オプションが異なれば URL も異なるので、別ファイルとしてみなされるからです。

もし、レスポンス時にクライアント側や CloudFront のキャッシュを制御する場合は response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'max-age=604800, s-maxage=31536000' }] 等とします。ただ s-maxage は CloudFront 側の設定との兼ね合いがある為、ここでは設定せず CloudFront 側のキャッシュ期間の設定に委ねた方が安全かもしれません。

l.145

class FormatError extends Error {}

Promise チェーン中で発生したエラーを区別する為のカスタムエラーです。

ローカル開発環境について

サンプルコードのリポジトリを見てもらえば分かると思いますが、特にフレームワーク等は使っていません。ただし機能の開発中にローカルでも実行できるようにはしてあります。オリジナルレスポンスはダミーの JSON で代替していますが、関数から S3 には実際にアクセスして画像を取得します。開発中はダミーの JSON の中身を適宜変更し、画像ファイルはテスト用の画像を S3 に置いてください。

ローカルの Node.js のバージョンは AWS 上の Lambda の実行環境と合わせて 6.10 としてください。またローカルから S3 へアクセスする為に、プロジェクトディレクトリの一つ上の階層に AWS SDK をインストールしておきます。

$ npm install aws-sdk

ローカルで関数を実行するにはコンソールで次のようにします。

$ npm run local-run

変換後の画像については base64 エンコードされた文字列がコンソールへ表示されます。この環境を拡張して画像を保存・表示するようにするとより良いかもしれません。

AWS の管理コンソールへアップロードする為のアーカイブ(***.zipファイル)を作成する場合は、コンソールで次のようにします。

$ npm run create-package

ローカル開発環境については下記のスライドにも書いたので、よろしければ参照ください。これは以前に私が 東京Node学園 でトークした際の資料です。Lambda@Edge 用でなく通常の Lambda 関数の開発について説明した資料ですが、14ページ以降のローカル環境についての記載は Lambda@Edge でも共通です。

関数のアップロードと動作確認

作成した関数を実際に AWS 上で動かすには、先の手順で作成した関数の枠組みを開き、アーカイブをアップロードします。今回のサンプルコードではメインロジック index.jssrc ディテクトリ配下に置いてあるので、「ハンドラ」には src/index.handler を指定します。

f:id:hkusu:20180524154940p:plain

また「メモリ」「タイムアウト」も適宜、変更しておきます。元画像の大きさによりますが、経験的にはメモリは 1024 MB、タイムアウトは数秒あれば十分そうですが、ここでは余裕をもってそれぞれ 2048 MB、15 秒を指定することにします。このあたりは元画像と画像変換の内容によるので適宜、調整してください。

f:id:hkusu:20180524154946p:plain

設定を保存したら、新しい「バージョン」を発行します。

Lambda 関数はコードと設定をひとまとめにして履歴管理できます。この操作は1つの履歴のバージョンとして保存するという意味です。

f:id:hkusu:20180524154951p:plain

バージョンが作成されたら、このバージョンのコードおよび設定を CloudFront と関連づけします。トリガーとして CloudFront を選び次のように設定を行います。

f:id:hkusu:20180524154955p:plain

f:id:hkusu:20180524155001p:plain

設定を保存し、CloudFront へ反映されるのを少し待った後、ブラウザで CloudFront のホスト + 画像の URL へ変換オプションのクエリ文字をつけてアクセスしてみます。指定したサイズの画像が表示されれば OK です。

f:id:hkusu:20180524155006p:plain

おわりに

Lambda@Edge で画像をリアルタムに変換する仕組みについて紹介しました。今回のサンプルコードは画像のリサイズと WebP 形式への変換というシンプルなものでしたが、更に画像のフィルター加工(ぼかし等)や画像のクロップ(切り抜き)、また画像の合成等を実装してみると面白いかもしれません。

冒頭のとおりまだ実運用では試してないので、今後もし実際に運用する機会があったらそこで発生した問題や解決方法、知見をまた紹介したいと思います。また画像変換に関わらず Lambda@Edge を実際に運用してみた、などの事例がありましたら是非ブログ等で紹介いただければ幸いです。

参考にしたサイト

iOSアプリのサブミット自動化と証明書管理の効率化

こんにちは。技術部モバイル基盤グループの @ です。

fastlaneCore Contributorを務めており、 社内ではプロのコードサイン解決者 *1 としての職務経験を積んでいます。

今回はクックパッドでのfastlaneを使ったiOSアプリのサブミット自動化と、証明書管理についての事例を紹介したいと思います。

CIによるiOSアプリサブミットの自動化

クックパッドでは、昨年の春頃よりiOSアプリのサブミットをチャットbot経由で行っています。

このように、Slack上でサブミットジョブを実行すると、CIでアプリがビルドされ、審査提出までを完全自動で行ってくれます。

f:id:gigi-net:20180516173922p:plain

審査提出には、ビルドや処理待ちの時間を含めると多くの工数がかかり、人為的なミスが起こる可能性もありましたが、 完全な自動化により、高頻度のアプリリリースに耐えられるようになりました。

アーキテクチャは以下の図のようになっており、チャットbotからJenkinsのジョブを実行し、そこでfastlaneを利用しています。

f:id:gigi-net:20180516173935j:plain

詳しく知りたい方は下記の資料をご覧ください。

この仕組みは、昨年まではクックパッドアプリでのみ行っていました。

しかし、今年に入ってからライブ配信アプリのcookpadTVや、レシピの投稿者がより使いやすいクックパッド MYキッチンなど、新規アプリの開発が活発になり、ほかのアプリでも自動サブミットの仕組みを導入する必要が出てきました。

このような仕組みをスケールするに当たって、一番の障害になるのがやはりコードサインです。 クックパッドでは、複数台のMac端末をCIサーバーとして運用しており、その全てに、多くのProvisioning Profileを配布、更新する必要がありました。 これらを手動で管理するのは現実的ではありません。

fastlane/matchを使った証明書管理

そこで、fastlaneのユーティリティの1つであるmatchを利用して、証明書やProvisioning Profileの管理、配布を自動化しました。

matchの仕組み

matchは、証明書や秘密鍵、Provisioning Profileを、git管理し、複数の環境で共有できるようにするツールです。 Apple Developer CenterのAPIを叩いて証明書やProvisioning Profileを作成し、暗号化を施してgitリポジトリに共有してくれます。

f:id:gigi-net:20180516173944p:plain

まず、iOSアプリケーションのリポジトリにMatchfileという設定ファイルを設置します。 これで、match利用時にデフォルトで設定されるパラメータを指定できます。

ここでは、コミット先のリポジトリを予め指定しています。

git_url "git@example.com:cookpad/certificates.git"

次に、開発者は開発環境からProvisioning Profileを作成します。matchはCLIを提供しているため、それを使うのが便利です。

typeを指定することで、ストア配布用のほか、AdHocビルドや、Enterprise配布用のProvisioning Profileも作成できます。

$ fastlane match --type appstore \
                 --app_identifier com.cookpad.awesome-app,com.cookpad.awesome-app.NotificationService

この操作で、Provisioning Profileが生成、コミットされました。

暗号化、復号化に利用するパスフレーズは、初回起動時のみ対話的に聞かれ、以後はmacOSのキーチェーンに保存されます。 また、MATCH_PASSWORDの環境変数で指定することもできます。

CIサーバーでビルドする際は、fastlaneを用いて、以下のように簡単に証明書の取得、コードサインを行うことができます。

Fastfile上に以下のように記述します。

# Sync certificates and Provisioning Profiles via git repository
match(
  app_identifier: ["com.cookpad.awesome-app", "com.cookpad.awesome-app.NotificationService"],
  type: 'appstore',
  readonly: true,
)

# Build iOS app with the profiles
build_ios_app

readonlyは、リポジトリやApple Developer Centerに変更を加えないようにするための設定値です。 CIサーバーからのProvisioning Profileや証明書の不用意な更新を防げます。

これにより、手元で証明書の更新、追加作業を行うだけで、全てのビルド環境で最新の証明書類が利用できるようになりました。

f:id:gigi-net:20180516173953p:plain

複数ライセンスでのmatchの利用

また、クックパッドでは、AppleのDeveloperライセンス(チーム)も、ストア公開用のライセンスのほか、社内配布用のEnterpriseライセンスを始めとした複数のライセンスを利用しています。

matchではライセンスごとに別のgitブランチを作成することで、複数のライセンスの証明書類を、1つのリポジトリで管理することができます。 CLIでは、以下のようにgit_branchオプションを渡します。

$ fastlane match --type enterprise \
                 --app_identifier com.cookpad.awesome-app,com.cookpad.awesome-app.NotificationService \
                 --git_branch enterprise \
                 --team_id $ENTERPRISE_TEAM_ID

この場合も以下のようにビルド時に証明書類を取得できます。

# Sync certificates and Provisioning Profiles via git repository
match(
  app_identifier: ["com.cookpad.awesome-app-for-inhouse", "com.cookpad.awesome-app-for-inhouse.NotificationService"],
  git_branch: 'enterprise',
  team_id: enterprise_team_id,
  type: 'enterprise',
  readonly: true,
)

# Build iOS app with the profiles
build_ios_app

運用してみての問題点

一見便利なmatchですが、今回大規模に運用してみて、下記のような問題に直面しました。

1ライセンス当たり同時に1つの証明書しか扱えない問題

matchの一番の問題点は、同時に管理できる証明書が1つに制限されてしまうという問題です。

Apple Developer Centerでは、1ライセンス当たり同時に2つの証明書を作成することができますが、matchでは、新しく証明書を作成したい場合は、match nukeと呼ばれる機能を使い、既存の証明書と、それを利用しているProvisioning Profileを全てrevokeする必要があります。

そのため、証明書がexpireする前に、新旧2つの証明書を用意し、徐々に切り替えていくという方法を取ることができません。

この問題はissueにもなっており議論されていますが、今のところ対応されておりません。

Provisioning Profile作成時に証明書のIDを渡すことで複数の証明書を管理できる仕組みを個人的に検討しており、そのうち開発したいと考えています。

Enterpriseライセンスの証明書更新で困る問題

サブミット用の証明書やProvisioning Profileは、revokeしても、すでにApp Storeにリリースしているアプリは影響を受けません。 上記の問題の影響を大きく受けるのがApple Developer Enterpriseライセンスです。

Enterprise証明書でApp Storeを経由せずに配布しているアプリは、証明書がrevokeされた瞬間に、全てのインストール済みの端末でその証明書を使って署名したアプリが動作しなくなります。

例えばクックパッドでは、最近Cookpad Studioという、ユーザーさんが実店舗で料理動画を収録できるサービスを展開しています。

こちらでは、全国のスタジオでEnterpriseライセンスで配布した業務用アプリを利用しているのですが、証明書のrevokeにより動作しなくなる危険性があります。

多くの端末を利用しているので、全国で同時に更新作業をするのは容易ではありませんが、現在のmatchでは即時のrevokeしかできないため、証明書の更新時に問題が発生することが予想されます。

この問題も、上記と同様に複数証明書の存在を許容することで解決できるでしょう。

複数ライセンス利用時にコミット先が制約できない問題

複数ライセンスの運用についても洗練されていない部分が目立ちました。

現在のmatchでは、Provisioning Profile作成時の操作ミスにより、1つのブランチに複数のDeveloperライセンスを混在させることができてしまいます。 これにより、不要な証明書が発行されてしまったり、解決が面倒な状態が発生してしまいます。

これは各ブランチにTeam IDを指定するファイルを含んでしまい、別のDeveloperライセンスで作成した証明書のコミットを禁止するなどの機能で対応できそうなので、このような仕組みを提案、実装したいと思っています。

まとめ

このように、iOSアプリのコードサイン周りは非常に複雑で、特殊な訓練や知識が必要になりますし、プロダクト開発において本質的ではない問題が発生しがちな領域です。

全てのアプリ開発者が開発に注力できるよう、今後もfastlaneの開発などを通して、生産性向上へ貢献していきたいと思っています。

クックパッドのモバイル基盤チームでは、アプリ開発者の生産性を向上させたいエンジニアを募集しています。

iOS アプリケーションエンジニア(開発基盤) Android アプリケーションエンジニア(開発基盤)

*1:Professional iOS Code Signing Issue Resolver. fastlaneのauthorである@の役職でもあります

Androidアプリ の minSdkVersion を21にした話

技術部モバイル基盤グループの こやまカニ大好き( id:nein37 ) です。今回はクックパッドにおける Android アプリの minSdkVersion を 21 にした話を紹介します。

クックパッドのモバイルアプリではユーザーが5%存在するプラットフォームではサービスを維持するというルールが存在していて、ここ数年はこのルールに従って minSdkVersion を決めてきました。 最後に更新されたのは2016年7月のことで、このときは Android 4.0.x (API level 14-15) のシェアが 5% を下回ったため minSdkVersion を 16 に更新しました。 その後、 Android 4.1 (API level 16) のシェアが5%を下回った際に minSdkVersion を見直す機会はありましたが、同じく Jelly Bean である 4.2 のシェアが高く 4.1 だけサポート外にしてもあまり効果が見込めないことから minSdkVersion の更新は行いませんでした。

そのような状況が1年近く続いていたのですが、最近クックパッドアプリだけでなく国内向けアプリ全体の minSdkVersion ポリシーを見直す機会があったため、その内容を書いていこうと思います。

minSdkVersion の定期的な更新が必要な理由

Android には Support Library という古いバージョンのOSに新しい機能をバックポートするためのライブラリがあります。(Google I/O 2018 ではさらに新機能も追加され Jetpack という枠組みが生まれました) また、 Google Play サービスや Firebase といったライブラリも独立したライブラリとして提供されているため、Android 4.0 (API level 14) 以上であればほとんどの機能を利用することができます。 Android 開発ではこれらのライブラリによって古いOSでもあってもある程度不自由なく開発や運用ができるようになっていますが、やはり限界は存在しています。

新機能のバックポートが遅い、または不十分である

最近では新しいOS(API level)の Developer Preview 提供とほぼ同時に新しい Support Library の alpha が提供されるようになりました。 しかし、その中でも古いOS向けにバックポートされていない新機能があり、どのOSバージョンでも最新OSと同じ機能を提供できるというわけではありません。 たとえば、 ImageView#setImageTintList() というメソッドは Android 5.0 (API level 21) から提供されていますが、 Support Library の AppCompatImageViewsetImageTintMode() が追加されたのは2017年7月リリースの v26 からで、 v25 で入った background tint のサポートからは7ヶ月遅れています。

また、同じくAndroid 5.0 (API level 21) で導入された JobScheduler は Android 5.0 以上でしか利用できず、過去のOS向けのバックポートである GcmNetworkManagerFirebase JobDispatcher でその機能をすべて置き換えることはできません。 その一方で JobScheduler 以前に利用されていた AlarmManagerWakefulBroadcastReceiver などの制限はOSバージョンアップのたびに厳しくなっており、ひとつの実装で全てのOSに同じ機能を提供することが難しくなっています。

このように古いOSが存在することでアプリの構成自体が複雑化していってしまうため、アプリの健全な開発効率を維持するためにも minSdkVersion の定期的な見直しは必要です。

バックポート不可能な機能の差異が存在する

例えば、以下のようなOSバージョンごとの差異は Support Library では埋めることができません。

  • WebView の挙動
    • Android 4.4 (API level 19)より前のバージョンではOSに組み込まれたWebViewコンポーネントが利用される
    • Android 4.4 (API level 19) では Chromium ベースになったがバージョンは固定で更新されない
    • Android 5.0 (API level 21) 以降では Chromium ベースの最新のコンポーネントが提供される
  • メディアサポート
    • 動画や静止画のサポート状況はOSバージョンによって異なる(ただし、 ExoPlayer など独自のメディアサポートを提供するライブラリは存在する)
    • MediaSessionなどの再生関連UIは 5.0 から追加された
  • TLS 1.1, 1.2サポート
    • TLS 1.1, 1.2 の実装は Android 4.1 から含まれているが、デフォルトで有効になったのは 5.0 から

これらの機能に強く依存したサービスの場合、 minSdkVersion を上げる以外の選択肢はなくなります。

スマートフォン・タブレット以外のプラットフォームサポート

Android Auto や Android TV といったプラットフォームは Android 5.0 (API level 5.0) から追加されました。これらの機能はより minSdkVersion の低いスマートフォン向けのアプリに同梱することもできますが、それぞれの機能は古いOSの端末から呼び出されることを想定していません。

これは極端な例ですが、Android TV で実際に発生した問題について説明してみましょう。Android TVではTV端末の判定のために UI_MODE_TYPE_TELEVISION を参照するように公式ドキュメントに書いてあります。 ところが、このフラグ自体はAPI level 1から存在するものであり、一部のSTB型端末はこのフラグが有効になっているため、 Android 4.0(API level 14) の端末であるにも関わらずTV端末として判定されます。 通常、TV はホームアプリが参照する category がスマートフォン・タブレットと異なるため画面が分離されていますが、上記のフラグだけに頼って TV 判定を行って leanback ライブラリの機能を呼び出したため、端末のAPIレベルに存在しないメソッドを呼び出してクラッシュすることがありました。(leanback ライブラリの minSdkVersion は 17 に設定されており、これより古いOSから呼び出した場合の動作は保証されません)

このような事故を防ぐために、 新しいプラットフォームをサポートする場合は minSdkVersion を見直したほうが良い場合もあります。

サポート外となったOSはどうなるのか?

これは新しいアプリのリリース後、以前のAPKをどうするかによって変わってきます。

何もしなかった場合、 minSdkVersion や uses-feature が異なるAPKが配信されると過去のAPKと新しいAPKは同時に配信され続けます。 この状態では最新のAPKでサポート外となった端末でも以前のAPKが新規にインストールできます。 この状態ではユーザーからは普通に自分の端末でアプリのインストールや利用ができるため、自身の端末がサポート外となったことはわかりません。

一方、Playコンソールから古いAPKを無効にすることもできます。 その場合、最新のAPKでサポートされなくなった端末ではアプリのインストールができなくなり、アプリのサポート外であることがわかるようになります。

OSのサポートバージョンを変更する方法として、 minSdkVersion の切り上げを行いつつ古いAPKは有効にしておき、古いOSのシェアが低くなった時点で過去のAPKも無効にする、とう方法も取ることができます。

minSdkVersion をどの値にするべきか?

minSdkVersion の設定値を決めるための基準は2つあります。

OSバージョンが一定のシェアを下回っているものをサポート外とする

minSdkVersion を上げる理由は主に開発・運用の効率化のためですが、当然サポート外となったOSバージョンにはアップデートにより最新のサービスを届けることができなくなってしまいます。 また、サポート外となったOS向けに配信されていたアプリに致命的なバグがあった場合、アップデートによる解決を行うこともできなくなります。

これらの問題によるユーザーへの悪影響を最小限にするため、クックパッドでは対象のOSバージョンが 5% を下回った場合に minSdkVersion を更新してもよい、というルールを設けています。 直近ではクックパッドアプリのOSバージョンごとのシェアは大まかに以下のようになっていました。

OSバージョン API level シェア
5.0.x 21 13.60%
4.4.x 19 7.9%
4.3.x 18 0.16%
4.2.x 17 3.67%
4.1.x 16 0.87%

Android 4.1-4.3 は一般に Jelly Bean と呼ばれているバージョンで、以前検討した際には「サポート対象外にするときはなるべく一緒のタイミングでやりたい」という判断にしていました。 以前は Andoroid 4.2(API level 17) のシェアが 5% を上回っていたため見送りましたが、今回は Jelly Bean 全体で合計しても 5% を下回っており、サポート外とすることができそう、という判断になります。

機能面・開発効率で比較して大きなメリットがありそうなものを閾値とする

前述の通り、通常の 5% ルールでは Jelly Bean をサポート外にできそうということがわかりました。 しかし、Android 4.4 (API level 19) もよく見るとシェア 7.9% という低めの値で、しかもひと月ごとに 0.5% を上回るペースで減少し続けていました。このままだと半年以内に 5% を切りそうです。 そこで、 minSdkVersion を Android 4.4 (API level 19) とした場合と Android 5.0 (API level 21) とした場合で簡単に比較してみることにしました。

  • Android 4.4 (API level 19)
    • AlarmManager の挙動変更やストレージ関連の変更など、挙動変更の閾値となる部分は多い。
      • ただし Android 5.0 では JobScheduler が導入され AlarmManager の用途が狭まっている
    • WebView が Chromium ベースになっており、 ウェブページ側の改修が楽
      • ただし WebView コンポーネントのアップデートは Android 5.0 から
  • Android 5.0 (API level 21)
    • JobScheduler 、Camera2 API など過去のOSでは利用できない大きな変更が多数含まれている。
    • Material Design にネイティブ対応しており、レイアウトXMLでの属性指定などにSDK側のものを利用できる。
    • 現状 JobScheduler を利用している料理きろくなどでOSバージョンごとの機能差が存在しているが、このバージョンまで minSdkVersion を引き上げることで内部の分岐がなくなる

上記を踏まえチーム内で議論した結果、今回の見直しで minSdkVersion を Android 5.0 (API level 21) とした場合、もっとも開発効率を引き上げることができるという結論になりました。ちょうど新規のアプリの開発・リリースがいくつか控えていたこともあり、半年後に再度 minSdkVersion を見直すよりも半年前倒しにして社内の国内向け全アプリに minSdkVersion 21 を適用することで大きなメリットがあると判断したためです。

社内でどのようにバージョンシェアの変更議論を進めたか

前述の通り「開発効率・運用工数の改善」という観点でみた場合、最も効果がありそうな閾値は Android 5.0 (API level 21)でしたが、Android 4.4(API level 19) の 7.9%のユーザーというシェアはかなり大きいものです。 すでに多数のユーザーを抱えるクックパッドアプリではサービス面での責任をもっている部署と何度も相談を重ねて慎重に進めていくことになりました。

一方これからリリースする新規のアプリでは既存ユーザーへの影響を考えなくて良いため、まずはそちらのチームにminSdkVersion を 21 から始めることのメリットについて「Android アプリの minSdkVersion(最小サポートOSバージョン) は Android 5.0 以降 にすべき」というブログを書いたり開発チームに直接説明したりして共有しました。 これらの取り組みの結果、cookpadTVアプリクックパッドMYキッチンアプリのいずれも minSdkVersion 21 からのスタートとなりました。今後リリースされる新規のアプリに関しても全て minSdkVersion 21 以上となる見込みです。

クックパッドアプリにおける適用は当初 Android 4.4 (API level 19) のユーザーシェア 7.9% という割合の多さから見送られそうになりましたが、アプリ全体のリファクタリングのための期間が始まるためその期間前に適用することがベストなタイミングであることを説明したり、ユーザーシェアの減少率の傾向やWebページの差し替えによる改善は引き続き可能であることを説明したり、経営層との「クックパッドアプリの開発を高速化するためにはどうすればよいか一旦数字を度外視して考えてみる」という場で取り上げたりした結果、近日中に minSdkVersion 21 に引き上げることになりました。 現在細かいリリース日時を調整中で、開発環境にももうすぐ反映見込みです。 minSdkVersion の更新後、代替リソースの整理やレイアウトファイルの見直しなどやりたいことがいっぱいで今からとても楽しみです。

おまけ

今回の取り組みの最中に minSdkVersion という謎のアカウントが値を 21 に更新していました。 世界的に minSdkVersion 21 の流れが来ているのだと思います。

最後に

今回はモバイル基盤の取り組みとしてAndroidアプリの minSdkVersion を 21 にした話を紹介しました。 モバイル基盤では今後も引き続きユーザーサポートとのバランスを取りながら開発効率を高める取り組みを行っていく予定です。 クックパッドではモバイル基盤と一緒に minSdkVersion 21 でアプリ開発を行いたい仲間、開発を効率化する仕組みづくりに興味がある仲間を募集しています

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/