Quantcast
Channel: クックパッド開発者ブログ
Viewing all 801 articles
Browse latest View live

ReactNativeプロジェクトのAndroid環境を整備する

$
0
0

投稿開発部の吉田です。React Native 新アプリシリーズ連載3日目はAndroid担当の私からReactNative(以下RN)のAndroidプロジェクトを出来る限り健康な状態にするために行ったことを紹介します。

手元の環境

RNは開発が活発なため執筆時点の環境を載せておきます。バージョンが乖離している場合は動かない可能性があります。

  • react-native-cli(2.0.1)
  • npm(5.8.0)
  • node(8.9.3)
  • react-native(0.55.1)
  • Android Studio(3.1)
  • macOS High Sierra(10.13.4)

Androidの開発環境はセットアップ済みとして話を進めます。

設定ファイルの掃除

react-native initで生成されたプロジェクトは丁寧な作りになっていますが、コメントが多すぎたり、設定が古かったりするので整えていきます。 RNはAndroidプラットフォームにそこまで依存性がないので、設定ファイルを大胆に書き換えても問題なく動作します。

AndroidStudioでプロジェクトを開く

AndroidStudioを起動してOpen an existing Android Studio projectを選択します。 ディレクトリは$project_root/androidを指定します。

起動すると"Android Gradle Plugin Update Recommneded"というタイトルのダイアログが出てきます。問題ないので更新しましょう。 内容はAndroidの標準のビルドシステムであるGradleの更新とGradleでAndroidProjectをビルドするためのプラグインの更新です。

Gradle pluginの更新が終わるとgradle syncやAndroidStudioのindexingが動き始めます。それらが終わると恐らく右下に下記の警告が現れます。

Configuration 'compile' is obsolete and has been replaced with 'implementation'.
It will be removed at the end of 2018


The specified Android SDK Build Tools version (23.0.1) is ignored, as it is below the minimum supported version (27.0.3) for Android Gradle Plugin 3.1.0.
Android SDK Build Tools 27.0.3 will be used.
To suppress this warning, remove "buildToolsVersion '23.0.1'" from your build.gradle file, as each version of the Android Gradle Plugin now has a default version of the build tools.

警告は2点あります。

  • ライブラリの依存関係を表す文法が変更され、古いcompileというシンタックスは2018年末を目処に削除されること
  • Android-Gradle-Plugin(3.1.0)はAndroid SDK Build Tools(23.0.1)をサポートしていないこと

上の警告は app/build.gradle以下のこの部分の事を指しているので素直に置き換えましょう

 dependencies {
-    compile fileTree(dir: "libs", include: ["*.jar"])-    compile "com.android.support:appcompat-v7:23.0.1"-    compile "com.facebook.react:react-native:+"  // From node_modules+    implementation fileTree(dir: "libs", include: ["*.jar"])+    implementation "com.android.support:appcompat-v7:23.0.1"+    implementation "com.facebook.react:react-native:+"  // From node_modules
 }

二つ目の警告も解決は簡単です。Android SDK Build Toolsについて詳しく触れませんが、Android-Gradle-Plugin3.0以降では指定しなければ常に最新のものが利用されるので該当箇所を削除します。

 android {
     compileSdkVersion 23
-    buildToolsVersion "23.0.1"

     defaultConfig {
         applicationId "com.sampleproject"

SDK Build Tools Release Notes | Android Studio

上記の作業後画面の右上からSync Nowすると全ての問題は解決したので警告は表示されなくなります。

compileSdkVersionとtargetSdkVersionを最新にする

Android開発にはcompileSdkVersionとtargetSdkVersionとminSDKVersionの3つのバージョン概念があります。それぞれの違いについては公式ブログに譲るとして、compileSdkVersionとtargetSdkVersionを最新にします。 (2018年4月地点では27が最新)

diff --git a/android/app/build.gradle b/android/app/build.gradle
index e37c508..e645022 100644
--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -94,12 +94,12 @@ def enableSeparateBuildPerCPUArchitecture = false
 def enableProguardInReleaseBuilds = false

 android {
-    compileSdkVersion 23+    compileSdkVersion 27

     defaultConfig {
         applicationId "com.sampleproject"
         minSdkVersion 16
-        targetSdkVersion 22+        targetSdkVersion 27
         versionCode 1
         versionName "1.0"
         ndk {
@@ -137,7 +137,7 @@ android {

 dependencies {
     implementation fileTree(dir: "libs", include: ["*.jar"])
-    implementation "com.android.support:appcompat-v7:23.0.1"+    implementation "com.android.support:appcompat-v7:27.1.1" //majorVersionがcompileSDKVersionと一致する必要がある
     implementation "com.facebook.react:react-native:+"  // From node_modules
 }

diff --git a/android/build.gradle b/android/build.gradle
index 3931303..7456eb1 100644
--- a/android/build.gradle+++ b/android/build.gradle@@ -17,6 +17,7 @@ allprojects {
     repositories {
         mavenLocal()
         jcenter()
+        google() //最新のsupportライブラリを取得するためgoogle-repositoryを追加している
         maven {
             // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
             url "$rootDir/../node_modules/react-native/android"

特にtargetSdkVersionを26以上に上げる作業はリリース前に必ずやっておくことをお薦めします。今年中にtargetSdkVersionの低いアプリのリリースや更新が制限される事が公式のアナウンスで発表されています。

targetSdkVersionを上げるとアプリの一部の振る舞いが変わります。一番わかりやすい例はRuntimePermissionの有効化です。他にも細かな振る舞いの変更があるのでこの辺りで一度アプリが正常に動くのかチェックしましょう。

見落としがちな変更にランチャー上のアプリアイコンの見た目の変化があります。(ランチャーアプリによっては想定より小さく表示される) この問題は、roundIconリソースの追加することで解決します。2018年現在ではついでにAdaptiveIcon対応もすることをお薦めします。AdaptiveIconとはランチャーアプリ側でアイコンの外形を決めることの出来る機能です。どちらにせよ対応は難しくないのでデザイナーと相談してandroid:roundIconを設定しましょう。

不要なパーミッションや宣言を取り除く

ReactNativeでAndroidアプリを作ると不要なパーミッションがデフォルトでついているので適切に削除します。

DevSettingsActivityの宣言をリリース版から削除する

DevSettingsActivityは名前の通りデバック用途のActivityです。リリースビルドには必要ないのでapp/src/debug/AndroidManifest.xmlにデバッグ用のマニフェストを作り移動させます。

--- /dev/null+++ b/android/app/src/debug/AndroidManifest.xml@@ -0,0 +1,6 @@+<manifest xmlns:android="http://schemas.android.com/apk/res/android">++    <application>+        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />+    </application>+</manifest>diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c765926..d4b02f4 100644
--- a/android/app/src/main/AndroidManifest.xml+++ b/android/app/src/main/AndroidManifest.xml@@ -28,7 +28,6 @@<category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
-        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" /></application>

 </manifest>

SYSTEM_ALERT_WINDOWをリリースビルドから削除する

SYSTEM_ALERT_WINDOWは他アプリの上への描画を許可する強めのパーミッションです。こちらもデバックビルドで利用されるものですがリリースビルドにも紛れ込むので取り除く設定を追加します。

--- /dev/null+++ b/android/app/src/release/AndroidManifest.xml@@ -0,0 +1,8 @@+<manifest xmlns:android="http://schemas.android.com/apk/res/android"+          xmlns:tools="http://schemas.android.com/tools"+        >+    <uses-permission+            android:name="android.permission.SYSTEM_ALERT_WINDOW"+            tools:node="remove"+            />+</manifest>

READ_PHONE_STATEを削除する

ReactNative for Androidはandroid-jscというライブラリに間接的に依存しています。 このライブラリのtargetSDKlevelが4と異常に低い影響でREAD_PHONE_STATEのパーミッションが勝手に追加されます。多くの場合不要なパーミッションだと思うので削除しておきます。

--- a/android/app/src/main/AndroidManifest.xml+++ b/android/app/src/main/AndroidManifest.xml@@ -1,8 +1,11 @@<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.sampleproject">+          xmlns:tools="http://schemas.android.com/tools"+          package="com.sampleproject"+        ><uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+    <uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove"/><application
       android:name=".MainApplication"

残りは好みですが不要な設定やコメントやファイルを削除します。

  • 不要なコメントの削除
  • keyStore以下の削除
  • libs以下のjarの参照削除
  • mavenLocalを参照レポジトリから削除
  • BUCKファイルの削除

Clean up project · kazy1991/techlife-rn-android-sample@8366400 · GitHub

apkサイズの最適化

必ずしもやる必要はないですが、ReactNative製のアプリはデフォルトの設定だと不必要にバイナリサイズが大きくなってしまうのでダイエットする方法を2つ紹介します。 私達の開発しているMYキッチンアプリでは下記の二つを行い10MB程度あったapkを5MBまで減らすことが出来ました。

Multiple APKs化

Androidは多種多様な環境の端末で動く可能性があるため、必要になりそうなものは全部詰め込んでいます。Multiple APKsとは環境毎にバイナリを分けることでサイズを削減する方法です。 ReactNativeの場合Native Developer Kit(NDK)の部分が大きいためCPUアーキテクチャ(ABI)毎にバイナリを分けてあげるとapkのサイズがかなり小さくすることが出来ます。 対応が難しそうですが、実はプロジェクトの雛形の中にdef enableSeparateBuildPerCPUArchitecture = falseというフラグが用意されているためtrueに切り替えるだけでMultiple APKsは完了します。

ちょっとした補足ですが、apkを分割する場合PlayConsoleの都合上versionCodeはそれぞれ異なる必要があります。テンプレートではaapt(Android Asset Packaging Tool)コマンドを使って確認しやすいように、既存のバージョンコードに1MB(1048576)を足し合わせた値を採用しています。これは個人の込みのですが、私たちはversionCodeをログ分析などでも利用する都合上パッと見のわかりやすさを重視して既存のversionCode+末尾1桁をABIの識別という形に変更しています。

diff --git a/android/app/build.gradle b/android/app/build.gradle
index f491bca..d8dc9bc 100644
--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -69,11 +69,11 @@ android {

     applicationVariants.all { variant ->
         variant.outputs.each { output ->
-            def versionCodes = ["armeabi-v7a":1, "x86":2]+            def versionCodes = ["armeabi-v7a": 1, "x86": 2]
             def abi = output.getFilter(OutputFile.ABI)
             if (abi != null) {
-                output.versionCodeOverride =-                        versionCodes.get(abi) * 1048576 + defaultConfig.versionCode+                // 例) version1.1.1のx86のversionCode: 101012+                output.versionCodeOverride = defaultConfig.versionCode + versionCodes.get(abi)
             }
         }
     }

https://developer.android.com/studio/build/configure-apk-splits.html

Proguardの有効化

Proguardとはプロジェクトを静的解析して参照のないコードを削除したり、難読化を行うツールです。 これも詳細は下記のリンクに譲りますが、こちらもdef enableProguardInReleaseBuilds = falseというフラグが用意されているためtrueに切り替えるだけで有効になります。 ネイティブモジュールを多用しない限り嵌まらないと思いますが、正しい設定がされていないままProguardを有効にするとビルドが通らなくなったり、実行時にアプリが意図せずクラッシュすることもあります。 有効に切り替える際には動作確認することをお薦めします。また可能ならProguardの振る舞いについて正しく理解できていると安心できます。

https://developer.android.com/studio/build/shrink-code.html

Stetho(デバッグツール)の導入

StethoはFacebook社が開発しているAndroid向けデバッグツールです。おおまかに言うとAndroid開発でもChromeのdevToolが使えるようになります。 特にNetwork Inspectionはとても便利なので導入しておくことをお薦めします。

f:id:kazy1991:20180409202337p:plain

一般的な導入方法は公式のドキュメントに譲りますが、一点だけ注意点があります。ReactNative:0.45~0.54を利用されている場合公式ドキュメント通りに導入しても動作しません。ReactNativeのバージョンを上げるか下記のリンクのように強引に差し込む必要があります。

Fix NetworkingModule losing Cookies when multiple CatalystInstances e… · facebook/react-native@0a71f48 · GitHub

バージョニングをsemverぽく変更する

ReactNativeを使った開発ではCodePushを利用することも多いと思います。CodePushと相性を良くするためにAndroid側のバージョニングもsemverっぽく管理すると便利です。 AndroidのバージョニングにはversionCodeversionNameがありますがMYキッチンアプリでは下記のように管理しています。

--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -7,6 +7,7 @@ project.ext.react = [
 ]

 apply from: "../../node_modules/react-native/react.gradle"
+apply from: "${project.rootDir}/gradle/appversion/appversion.gradle"

 def enableSeparateBuildPerCPUArchitecture = false
 def enableProguardInReleaseBuilds = false
@@ -18,8 +19,8 @@ android {
         applicationId "com.sampleproject"
         minSdkVersion 16
         targetSdkVersion 27
-        versionCode 1-        versionName "1.0"+        versionCode project.ext.getVersionCode()+        versionName project.ext.getVersionName()
         ndk {
             abiFilters "armeabi-v7a", "x86"
         }
diff --git a/android/gradle/appversion/appversion.gradle b/android/gradle/appversion/appversion.gradle
new file mode 100644
index 0000000..e9bdb5c
--- /dev/null+++ b/android/gradle/appversion/appversion.gradle@@ -0,0 +1,49 @@+class AppVersion {++    private int major++    private int minor++    private int patch++    AppVersion(int major, int minor, int patch) {+        throwExceptionIfVersionIsNotValid(minor)+        throwExceptionIfVersionIsNotValid(patch)++        this.major = major+        this.minor = minor+        this.patch = patch+    }++    private static void throwExceptionIfVersionIsNotValid(int version) {+        if (version >= 100) {+            throw new IllegalArgumentException("Can't use version number more than three digit")+        }+    }++    int getCode() {+        return major * 100_00_0 + minor * 100_0 + patch * 10+    }++    String getName() {+        return "${major}.${minor}.${patch}"+    }+}++ext {+    def vProperties = new Properties()+    vProperties.load(rootProject.file('version.properties').newDataInputStream())+    def versionMajor = vProperties.getProperty("version.major").toInteger()+    def versionMinor = vProperties.getProperty("version.minor").toInteger()+    def versionPatch = vProperties.getProperty("version.patch").toInteger()+    def appVersion = new AppVersion(versionMajor, versionMinor, versionPatch)++    getVersionCode = {+        return appVersion.getCode()+    }++    getVersionName = {+        return appVersion.getName()+    }+}+diff --git a/android/version.properties b/android/version.properties
new file mode 100644
index 0000000..9f61962
--- /dev/null+++ b/android/version.properties@@ -0,0 +1,3 @@+version.major=1+version.minor=0+version.patch=0

ReactNativeに限らないTips

紹介した内容以外にもReactNativeプロジェクトに限らない変更をいくつか入れているので簡単に紹介します。

おわり

ReactNaiveを使ったアプリ開発は評判通り高速に開発が可能でとても気に入っています。AndroidエンジニアはRNプロジェクトであまり活躍できない印象がありましたが、より安全に(例えば秘匿値の管理)より快適な体験(例えばSmartLock for passwordを使って自動ログインを実現するなど)を提供するために活躍する場面はたくさんあります。

もし私達の取り組みに少しでも興味を持って頂けたらぜひお声がけください。私個人へのDMでも大丈夫です! 😀

明日は@sn_taigaさんから 「クックパッド MYキッチン」のアプリアイコンができるまでのお話です。お楽しみに!

今回作成したプロジェクトのレポジトリはこちらです。

GitHub - kazy1991/techlife-rn-android-sample


「クックパッド MYキッチン」のアプリアイコンができるまで

$
0
0

こんにちは投稿開発部の佐野大河です。React Native 新アプリシリーズ連載4日目はReact Native のお話はしません。今日はその新アプリ「クックパッド MYキッチン」のアプリアイコンの制作過程について書こうと思います。

f:id:sn_taiga:20180418115300p:plain

連載初日の記事でもありましたように、クックパッド MYキッチン(以下、MYキッチンアプリ)は、レシピ投稿者に使いたいと思ってもらうことを目指したアプリです。ベースの機能はクックパッドとは大きく変わらない派生アプリのようなものですが、レシピ投稿者に好んで使い続けてもらえるように、全く新しいアプリとしてユーザーに届けたいという想いがありました。なので、MYキッチンアプリのアイコンを考えるにあたって、通常のクックパッドアプリのブランドイメージを引き継ぎつつも「MYキッチンアプリらしい」シンボルをいちから考えました。 今回はこのアプリアイコン完成までのプロセスを(迷走したことも含めて)紹介していきます。

🏃 まずは情報収集

まず最初にアイデアを出すための情報収集を行いました。MYキッチンアプリのサービス開発の過程で「アプリケーション定義ステートメントシート」というものを作成し、そこからMYキッチンアプリの要素を抽出しました。

アプリケーション定義ステートメントシート

ここではサービスのコアの価値やターゲット、ユースケース、利用シーンなどを定義しています。このシートの使い方については過去の記事でも紹介されているので興味のある方はご覧ください。

ここからサービス名の「キッチン」や「自分の場所」「記録、蓄積」「育てる」「整っている」といったものをキーワードとして上げました。また、サービスのキャッチコピーを「料理を楽しんでいるあなたに」とチームメンバーで定め、これも要素の一つとして取り上げました。

💭 モチーフを考える

これらの情報をもとにアプリアイコンのモチーフを考え始めました。

アイデアが浮かばない

がしかし、初めは思うようにアイデアが浮かばず、理想の形に全く辿り着けませんでした。

ラフスケッチ

ひとまずキッチン周りの道具で思い浮かんだものを一通り描き出してみたり、キーワードから連想する形を描いてそれらを組み合わせたりしながら色々模索しましたが、MYキッチンアプリのモチーフとしてしっくりくるものが全く出てきませんでした。

キーワードから発想を得ようとしても「自分の場所だから家?」「記録だからペン?メモ帳?」と直接的な発想しかできなかったり「こうしたら料理が楽しい雰囲気出そう?」「そもそも楽しいってどういう状態だ?」と具体的なイメージが定まらずにしばらく迷走しました。

f:id:sn_taiga:20180418143042p:plain

それでも現状思い付くものを具体化してみましたがとても納得できるものではありませんでした。

🔍 サービスを理解する

これ以上ペンを動かしても何も出てくる気がしなかったので、あらためてMYキッチンアプリというサービスについて理解しようと立ち戻りました。先輩デザイナーからのアドバイスも受けながら、まずはサービスを表現するためのキーワードの抽出を行いました。

キーワードを抽出する

初めはサービスのコンセプトやキャッチコピーを眺めてそれをただそのまま引用するようなことをしていましたが、視野を広げて「料理」に少しでも関係のありそうな形容詞を一旦洗い出し、その中から「MYキッチンらしい」と思えるワードを絞り込みました。

料理に関係しそうなキーワード

「MYキッチンらしいものは何か」「MYキッチンを使ったユーザーにどう思ってもらいたいのか」という考えのもと、イメージに近いものをオレンジ色に、逆にこれは違うというものを青色にしてチームメンバーに共有しました。メンバーからのフィードバックも受け、最終的にMYキッチンアプリを表すキーワードを以下のように整理しました。

f:id:sn_taiga:20180418142212p:plain

「楽しい」に「充実感」が加わり、自分の作った料理を見返して達成感を得たり自己満足したりするような、そういう楽しさが大事なんだとイメージが具体化しました。また、新しい料理を作っている人たちの「アイデア」や「創造性」「自由さ」といったものもMYキッチンアプリにとって重要な要素だと気づくことができました。

キーワードを視覚化する

整理したキーワードをもとにアイコンのムードボードを作成しました。ムードボードとは、具体的なアウトプットを出す前にデザインの雰囲気やトーンを視覚化したもので、他者との認識のずれをなくしたり、発想の元にしたり、その後の制作の手助けをしてくれます。

f:id:sn_taiga:20180418142350p:plain

ムードボードを作成することで抽象的だったキーワードのイメージが視覚化されていきます。そうすると全体を通して見えてくるものもあり、例えば「静的じゃなく動的」「動的ではあるがカオスまではいかず秩序のとれた形」「単色ではなく複数色」といったように、MYキッチンらしさを表す上でキーになりそうな要素が色々と見えてきました。(このとき、最初に考えた案がいかにMYキッチンらしさを表現できていなかったかをあらためて実感しました)

サービスの人格を作る

アプリのイメージをより固めるために「MYキッチンアプリというサービスを擬人化したらこんな人」を視覚化しました。

サービスの人格

ベースとなる人物やプロフィール写真はチームメンバーが共通で知っている人物を用いると認識のずれが少なくなって良いです。

f:id:sn_taiga:20180418142422p:plain

また、この人格を憑依させたスマホのホーム画面を作り、アイコン案を並べて見てモチーフやクオリティの良し悪しを判断するのに用いました。

✍️ 形にする

ここまで整理した要素を元にもう一度手を動かし始めました。

ラフスケッチ

初めのときに比べ表現したいキーワードのイメージが固まり、ムードボードを作成したことで形の着想もしやすくなりました。キーワードから連想できる形を描き出し、具体化して色を付けたり要素同士を組み合わせたりしながらモチーフのアイデアを出していきました。

ラフスケッチ

結果、波を描いた鍋またはボウルから具材が広がっていく形が、料理をしている人の楽しさや創造性といったものを表現できそうで良いとモチーフの方向性を定めました。また、配色はブランドカラーのオレンジをメインに、アプリの主要機能である「キッチンモード」のテーマカラーに用いている緑と、色相の近い黄色または茶色を段階的に使い、動きを作りつつ全体をまとめる方向で進めました。

f:id:sn_taiga:20180418142457p:plain

上部のパーツを抽象化させ、配色や形状のパターンを出し、その中からbowlの5番が良いとなり最後の詰めの作業に移りました。

🎨 磨き込む

アプリアイコンとしてのクオリティを上げるための磨き込みを行いました。

視認性を上げる

「ホーム画面で他のアプリと並べたときに埋もれてしまわないか」という点を意識し、アイコンの視認性や存在感を上げる修正を行いました。

f:id:sn_taiga:20180418142542p:plain

まず上部のパーツをよりシンプルにしました。パーツ数が減って動きや広がりの印象が弱まった分、葉の大きさに差を付けて奥行きを出したり、緑の明度を若干下げてくっきりさせたり、パーツの向きやバランスを調整したりしました。

f:id:sn_taiga:20180418142611p:plain

柄の形状もいくつかパターンを試し、ホーム画面で見たとき一番視認性の高かった5番を採用しました。

このように形状や色味の調整を行いましたが、まだ他のアプリアイコンに比べて印象が弱いなと思うところがありました。そこで、元々全体のまとまりを出すために配色を3色に抑え段階的に使っていましたが、オレンジや緑に対してアクセントになる色を取り入れて印象を強めることを考えました。

意味を持たせる

印象を強めるのと同時に、色や要素に意味を持たせることも合わせて行いました。

ここまで漠然とボウルや具材と置いていたけど「この人が作っているものは何だろう」という疑問を抱き、「ボウルと泡立器ならスイーツ系かな」「緑は葉を表しているけど黄色の丸は何?」「赤青系の色ならベリーと連想できそうだ」といったように発想し、モチーフと色との意味付けをしました。

そうと決まれば、赤青系の中からベストな色を選んでいきます。

f:id:sn_taiga:20180418142647p:plain

予定通りアクセントの色を加えたことで全体的に印象が強まりました。赤〜青とその周辺の色を試し、サービス人格と比較したりホーム画面に置いたりしながら評価していきました。結果、赤寄りの色は楽しさが出る反面若々しすぎて人格よりも5~10歳程下だなと思ったり、紫寄りは少しお洒落感が出てちょっと違うなと思ったりして、2番の青が良さそうとなりました。

f:id:sn_taiga:20180418142711p:plain

さらに青の中でも微調整を行い、最終的に2_Aが一番アプリのイメージに一致していてこれがベストだと決定しました。

完成

アプリアイコン

あらためてクックパッドMYキッチンのアイコンは、料理を作っている人たちの「楽しさ」や「充実感」、新しい料理を生み出す「豊富なアイデア」や「創造性」「自由さ」、またそれらが蓄積し広がっていく状態を表しています。

🍵 まとめ

今回の制作過程で強く実感したのは「形が出てこない = サービスの理解が足りていない」ということで、もちろん自身の発想力不足というのもありますが、初めは「良い形を思いつかなければ」とアイデアを捻り出す感覚だったのに対し、サービスの理解や整理を徹底した後では「自然に形ができていた」という感覚に近く、最終的に納得のできる形にもっていくことができました。 紹介したプロセスは一例で他にも良い手法が様々あると思いますが、これからサービスのアイコンやロゴを作ろうとしている方の参考に少しでもなれば幸いです。明日はディレクターの五味夏季さんから「料理する人の課題を起点に施策を作る試み」のお話です、お楽しみに!

また、今回作成したアプリアイコンのコンセプトを、アプリ内でも強く実感できるようなサービス開発をこれからどんどん行なっていきます。興味を持たれた方がいらっしゃれば採用ページを是非ご覧ください。軽く話を聞いてみたいという方は@sn_taigaにDMしていただいても大丈夫です😄

料理をつくる人はどんな課題を抱えているのか? 〜ユーザーの課題を施策につなげるインタビューの取り組み〜

$
0
0

こんにちは。投稿開発部 ディレクターの五味と申します。 初日の記事から5日間に渡ってお届けしてきた「クックパッド MYキッチン」の連載も、いよいよ今回が最終回です!

私たち投稿開発部では、クックパッドユーザーの中でも特に「レシピを投稿するユーザー」にとって最適なアプリを追求するために、「クックパッド MYキッチン」アプリをリリースしました。ではこれからそこで、ユーザーにどのような体験や機能を提供していくべきでしょうか。

今回は、ユーザーの課題を起点に次の施策を発想していくために行なっている、ユーザーインタビューの取り組みについて紹介します。

f:id:natsuki53:20180419194028p:plainApp Store / Google Play

料理をつくる人はどんな課題を抱えているのか

投稿開発部は現在「クックパッドにレシピを投稿するユーザーを増やすこと」を目標とし、その戦略として「レシピ投稿の継続率を改善すること」と「レシピを投稿し始める人を増やすこと」の2点に注力することを決めています。

施策を考える上では、目先の数字を稼ぐのではなく、レシピ投稿を、何らかユーザーの課題を解決する、ユーザーが使いたくて使う手段にすることを重視しています。それを実現する指針を得るためには、料理をつくる人が抱えている課題を知り、できるだけ深く理解をする必要があります。

チームではそのため、アプリ開発と同時並行でユーザー調査を進めてきました。

調査の計画:ユーザーの実際をもっと知りたい

ところで投稿開発部は2018年に新設された部署です。メンバーは部長含め、サービス開発の経験はあれど、レシピ投稿者にフォーカスして取り組むのは初めての面々でした。さらに部内には、ユーザーインタビューを通してユーザーの課題を発見するための設計をしたことがある人もいません。そのため、部の発足当初から、定性的なユーザー調査に積極的に取り組むことは計画していました。

2月に入り「クックパッド MYキッチン」アプリの開発目処が立ってきた段階で、計画の実行に着手しました。投稿開発部のメンバーは7名と多くはないのですが、知見を自分たちのものにするためにも、インタビュー対象者のリクルーティングからすべて自分たちの手で行うことにしました。

まず有効な調査手法を手っ取り早く学ぶため、『ユーザビリティエンジニアリング*1』を参考図書とさせていただきました。書籍を読んで、サービス開発で重用する定性的ユーザー調査は大きく3つに大別されると考えました。

▼私の理解は以下の通りです f:id:natsuki53:20180419192007p:plain

当時ようやく主要機能が動くようになっていた「クックパッド MYキッチン」アプリがある状況で、投稿開発部としてユーザー調査を始めるなら、そのアプリを用いたユーザビリティテストから行うことが順当に思えました。

開発チーム全員が参加するインタビューに

インタビューを始めるにあたり意識していたことが、この取り組みにエンジニア含めたチーム全員が直接関わり、ユーザー理解を一緒に深められるようにすることです。

そのため、すべてのインタビューに中継機材とカメラを用意し、別室に待機するチームメンバーがインタビューの様子を同時中継で見られるようにしました。モニター室では各自メモを取ってもらいながら、ユーザーに聞きたいことがあれば、ファシリテーター役をしている私のPCにチャットで質問を入れられるようになっています。これによって、メンバー全員が当事者としてインタビューに関わることができるようになりました。

インタビュー開始と早々の方針見直し

ユーザーのリクルーティングや調査票・スクリプトの設計、会議室や機器設置に手間はかかったものの、リハーサルなども行いながら、何とか3週間程度でインタビューの開始に漕ぎつけました。しかも実際テストを開始してみると、新しいアプリは思った以上に円滑に操作され、用意したタスクリストは大きな問題なくこなされ、上々に過ぎていきそうに見えます。しかしその反面、ポジティブな反応も希薄なことが徐々に気がかりになってきました。

チームの興味は段々、アイスブレイクとしてヒアリングしていたユーザープロファイルに移っていきました。思えばチームメンバーにとって、「既存/潜在作者」とされる社外のユーザーにきちんと話を聞くのはそれが初めてだったのです。その人物像や生活背景への自分たちの理解の浅さに気がつくまで、時間はかかりませんでした。

結局私たちはインタビュー開始3日目には、開発中のアプリのユーザビリティよりもずっと前の段階に自分たちの問題点があると判断し、テストは早々に切り上げ、ターゲットユーザーの日常生活に潜む課題を見つけるための生成的調査へ転換することにしました。

生成的調査の試行錯誤

既に存在するプロトタイプの調査をするユーザビリティテストに比べ、まだ目に見えないユーザーの課題を対話の中で引き出す生成的調査は、難易度が高いと思います。この種のインタビューは漫然と行うと、得られる学びも漫然のまま終わることは経験してきているため、やるからには確固たる学びを得たいと考えていました。

- 苦慮したポイント1:設問設計

設問設計には当初から苦心しました。先述の『ユーザビリティエンジニアリング』には、ユーザーとの対話の中からキーワードを見つけて根掘り葉掘り質問をしていく手法をお薦めされており、「事前にインタビューガイドを作っても絶対にその通りにインタビューするな」と書かれています。しかし私たちのインタビュー対象者は、普段実際にクックパッドをご利用いただいているユーザーさんでもあるので、会話が途切れたり失礼を働くことが怖くなってしまい、結局、電話営業のトークスクリプト並みにがっちりと設問を並べた台本を用意してしまいました。

- 苦慮したポイント2:インタビュー後の振り返り

インタビュー後の振り返りの仕方にも悩みました。そもそもの課題抽出を目的とする生成調査は、検証調査やユーザビリティテストのように、答えを出す項目が先に存在するわけではありません。事前に想像できる範囲には限りがあるため、結局最初の1〜2回のインタビューを実地演習と割り切っていくつかの手法を試すしかありませんでした。

振り返りは、モニター室でインタビューを見ていたメンバーのふせんメモとホワイトボードを使うのが効率的です。ふせんメモをネガティブ/ポジティブで整理してみたり、対話中に見せたプロトタイプの種類別に整理してみたり、いくつかパターンを試した結果、ユーザーに語られたエピソードからメンバー各自気になったものを出し合い、「事実」と「(ユーザーご自身の)意見」に分けて整理する手法に今は落ち着いています。

「事実」「意見」ごとにKJ法を用いて重要そうなキーワードを見出したら、その周辺のエピソードを洗い出し、背景にある動機を想像して、各対象者が持っている「料理に関する課題」を推察して書き出せたら、その回の振り返りは完了です。

▼振り返り時の板書のイメージ(内容は架空です) f:id:natsuki53:20180419192140p:plain

振り返りの手法が固まると、インタビューの構成も、こちらが用意する質問に広く浅く答えてもらうのではなく、ユーザーの言葉で普段の料理や食事を取り巻く状況を語ってもらうものになっていきました。それに従い、設問もおのずとシンプルになります。当初20問近くあった設問が、最終回のインタビューでは2問まで減ったのは、とても印象的な出来事でした。

f:id:natsuki53:20180419192214p:plain

得た学びを確実にする作業:総括レポート

ユーザーの普段の料理の状況を根掘り葉掘り聞き出し、インタビューごとにチームで振り返りを行って、彼らが料理に関して持っている重要な課題を書き出す手法により、個別のインタビュー対象者ごとの理解は深められるようになりました。対象にしたユーザーごとに書き出した課題をリスト化して、次の施策を考えるネタにもできそうです。

しかし、インタビュー全体としての評価はどうでしょうか?得られた学びが何だったか、明言はできるでしょうか?コストをかけてしっかり行ったインタビューなので、結果はうやむやにせず、チーム全員で得た学びを確実にものにしたいです。

そこで私たちは、普段プロジェクトごとに作成している施策設計・評価用のレポート「report.md*2」を、インタビュー調査でも作成するようにしました。

インタビュー調査は機能開発とは施策設計が異なるため、report.mdの構成も少々見直し、以下の項目で作成しています。

- 目的
- 対象ユーザー
- スクリプト
- 議事録
- 結果
- 次のアクション

インタビュー調査の計画時・完了時にこのレポートをGitHub Enterpriseの自部署のリポジトリにPull Requestで作成し、チームメンバーにレビューしてもらいます。こうして1枚の簡潔なレポートにまとめることで、調査計画も、全回のインタビューを経た考察や結論も明確に提言され、またチームメンバー間での理解の差も埋められ、結果を次の施策に生かしやすくなりました。

「クックパッド MYキッチン」での検証へ

私たちはいま、このインタビュー調査から得たユーザーの課題リストを元に、次に注力するユーザー課題を絞って取り組んでいます。メンバー全員が一連のインタビューの様子や結果を共有しているため、調査以前に比べてターゲットの人物像から具体的に解決策のイメージが湧くようになったことを実感しています。

ここから得た理解やアイデアを元に、次の施策のプロトタイプを鋭意進めています。近日中に「クックパッド MYキッチン」アプリで検証を始める予定でおりますので、リリースを楽しみにお待ちください。

アプリのインストールはこちらから! App Store / Google Play

終わりに

5日間に渡りお届けしてきた「クックパッド MYキッチン」の連載記事、いかがでしたでしょうか。最後に今一度、これまでの連載記事と、執筆した開発メンバーを紹介させていただきます!

クックパッドMYキッチンアプリ、ならびに、投稿開発部にご興味を持ってくださった方、一緒に開発に取り組んでくださるメンバーを募集中です!

採用サイトまで、お気軽にお問い合わせください!!

*1:橋本徹也 著『ユーザビリティエンジニアリング(第2版)―ユーザエクスペリエンスのための調査、設計、評価手法』amazon

*2:クックパッドのサービス開発チームで行なっている、施策レポートの取り組み。詳細は私の昨年の記事で触れています。

【開催レポ】Cookpad Tech Kitchen #15 〜料理動画・広告のBtoB領域の開発事情〜

$
0
0

こんにちは!人事部の冨永です。

2018年3月28日に「Cookpad Tech Kitchen #15 〜料理動画・広告のBtoB領域の開発事情〜」を開催しました。クックパッドではこのイベントを通して、技術やサービス開発に関する知見を定期的に発信しています!

第15回の今回は、最近リリースをしたクッキングLIVEアプリ「cookpadTV」をはじめとした料理動画事業の開発の裏側や、クックパッドの広告配信周りの開発について等をテーマに、7名が登壇しました。アプリからバックエンドまで具体的な事例を用いてたっぷりとご紹介したイベントの様子をお届けします🎉

cookpad.connpass.com

クックパッドの料理動画事業をご紹介🎬

まずはクックパッドの料理動画事業を簡単にご紹介します。

クッキングLIVEアプリ「cookpadTV」ライブ配信を見ながら、プロの料理家やシェフから料理を学んだり、有名人と一緒に料理を楽しむことができるアプリです。コメント機能を使ってわかりづらいポイントをその場で質問でき、双方向型コミュニケーションを実現した新しいクッキングアプリとなっています!また、クックパッドに投稿されているレシピの1分動画もご覧いただけます。ぜひ使ってみてくださいね ♪ App Store: https://itunes.apple.com/app/id1344736966 Google Play: https://play.google.com/store/apps/details?id=com.cookpad.android.cookpad_tv

「cookpad storeTV」全国のスーパーマーケットなどの流通チェーンと連携し、店頭の生鮮売り場にクックパッドオリジナルのサイネージ端末設置して料理動画を配信するサービスです。2018年1月より、大手食品・飲料メーカーの商品を活用した料理動画広告のトライアル配信も開始しております。あなたのお家の近くの店舗でも見られるかも!

詳細はこちら:~クックパッド、料理動画事業に本格参入〜第1弾は『cookpad storeTV』大手流通チェーンと連動し、売場で料理動画を配信12月より日本全国のスーパーマーケット約1,000店舗にてスタート

「cookpad studio」クックパッドスタジオは、クックパッドのレシピ投稿者が自分のレシピを動画にするために、無料で利用できる動画撮影スタジオです!動画の撮影経験がない初心者の方でもクオリティの高い料理動画の制作が可能です。完成動画はクックパッド内で公開できるほか、Instagramをはじめとする個人のSNSにも投稿することが可能です。昨年12月に東京・代官山に1号店をオープンし、今年5月には心斎橋に2号店をオープン予定です!公式ページはこちら:「cookpad studio」

これらの事業に沿って、それぞれのメンバーが何を開発しているのか、どんな技術を使っているのか、何を目指しているのかといった内容をお話しました。

f:id:mamiracle:20180423214451j:plain(cookpad storeTVのサイネージ端末)

cookpadTVのライブ配信の裏側

まずはじめにAndroidエンジニア石本と、サーバーサイドエンジニア長田からクッキングLIVEアプリ「cookpadTV」のコンセプトやアプリ構成についてお話しました。LIVE配信・コメント・いいね機能などに関する技術的課題と工夫についてお話しました。

speakerdeck.com

speakerdeck.com

cookpadTVのアプリ開発〜現状とこれから〜

iOSエンジニアの鶴川からは、クッキングLIVEアプリ「cookpadTV」の技術構成や、今後の展望についてお話しました。現在はプラットフォーム間での挙動や体験の違いを、技術的アーキテクチャの統一により解決することを目標にしており、それを前提に置いた基盤開発の工夫についてもご紹介しました。

speakerdeck.com

cookpad storeTVの開発事例

サーバーサイドエンジニアの三浦からは、動画サイネージ事業「cookpad storeTV」の開発事例をご紹介しました。ユーザー(店舗でのサイネージ端末運用者)が使いやすいサービスの設計や、それを実現するために工夫した技術についてお話しました。

speakerdeck.com

cookpad studioでの撮影環境について

iOSエンジニアの氏からは、ユーザー向け料理動画撮影スタジオ「cookpad studio」での、カメラ選定や通信方法などといった撮影環境構築についてお話しました。撮影に適した環境をつくるまでのガジェット選定や設計の工夫などについて詳しくご紹介しました。

speakerdeck.com

動画事業でのデータ収集、分析、活用

サーバーサイドエンジニアの今井からは、動画サイネージ事業「cookpad storeTV」のログの活用方法についてお話しました。当時苦戦してた課題から、現在導入しているTableau®の事例までをご紹介しました。

speakerdeck.com

cookpad storeTV 広告配信 いままでとこれから

サーバサイド / Androidエンジニアの中村からは、動画サイネージ事業「cookpad storeTV」における広告配信についてお話しました。店舗に設置したサイネージに動画広告の配信をしており、これからは imp ベースで行っていきます。

speakerdeck.com

付箋形式でお答えするQ&Aディスカッション😊

Cookpad Tech Kitchenではより気軽に質問をしていただくために、付箋で質問を集めています。この日はメディアプロダクト開発部長 渡辺が司会となってQAディスカッションを行いました。

f:id:mamiracle:20180423214502j:plain(付箋に質問を貼ってもらいます)

アプリのアーキテクチャでAndroidとiOSで統一することでUXを統一できる理由が知りたいです

アーキテクチャを統一することで、API へのリクエストタイミング、エラーハンドリング等の設計がプラットフォーム間で共有出来るようになるからです。そうすることで、ローディングの時間やエラーダイアログの表示等をあわせやすくなり、結果的に UX を似せることが出来ます。

cookpadTVのアプリ開発が少数精鋭で驚きました。レビューはどうして回していたのでしょうか?

レビューに関しては正直自分達のチームだけでは回しきれない部分があったので、技術部モバイル基盤チームのメンバに手伝って貰っていました。

ログの量が多そうですがデータ量削減のための工夫はしていますか?(圧縮形式など)

クックパッドのログ基盤はしっかりしているので、サービス側から送るログの量を制限して欲しいという依頼は来たことがありません。そのため、データ量削減の工夫はしていません。

ほんの一部ではありますが、上記のような質問にお答えしました!

シェフの手作り料理🍳

Cookpad Tech Kitchen ではおもてなしの気持ちを込めてシェフ手作りのご飯を振る舞っています!食べながら飲みながらカジュアルに発表を聞いていただけるように工夫しています。みなさまも次のイベントはぜひ遊びに来てくださいね。

f:id:mamiracle:20180423214534p:plain(オリジナルロゴケーキと陳麻婆豆腐焼きそば)

f:id:mamiracle:20180423214521p:plain(いつも美味しいご飯を作ってくださる川嶋先生)

クックパッドでは仲間を募集しています😊

今回のイベントでは、最近リリースした、料理動画事業や広告事業に関する新規サービスについてご紹介しました。ぜひこれを機にみなさまにもクックパッドの料理動画サービスを使っていただけたら嬉しいです。

そしてクックパッドでは料理動画事業や広告事業、その他新規事業、レシピサービス事業などに携わる新しい仲間を募集しています!クックパッドで「毎日の料理を楽しみにする」ことに興味を持ってくださった方は、ぜひ下記のリンクからご応募もお待ちしております。

Android アプリケーションエンジニア(料理動画・広告配信)

バックエンドエンジニア(料理動画・広告配信)

iOSアプリケーションエンジニア(料理動画・広告配信)

また、今後のイベント情報についてはConnpassページについて随時更新予定です。イベント更新情報にご興味がある方は、ぜひメンバー登録をポチっとお願いします!

cookpad.connpass.com

cookpadTV ライブ配信サービスの”突貫” Auto Scaling 環境構築

$
0
0

 インフラや基盤周りの技術が好きなエンジニアの渡辺です。

 今回は私が開発に関わっている cookpadTVの Auto Scaling 環境を突貫工事した事例をご紹介します。 同じチームのメンバがコメント配信回りについても書いていますので興味があれば合わせて読んでみてください。

クッキングLIVEアプリcookpadTVのコメント配信技術

 本エントリは Amazon EC2 Container Service(以降 ECS) をある程度知っている方向けとなっています。 細かいところの説明をしているとものすごく長文になってしまう為、ご了承頂ければ幸いです。

Auto Scaling について

 一般的に Auto Scaling は CPU やメモリ利用量によって増減させるのが一般的です。*1私が属している部署で開発保守している広告配信サーバも、夕方のピークに向けて CPUUtilization の最大値がしきい値を超えたら指定台数ずつ scale-out するように設定しています。 scale-in も同じく CPUUtilization が落ち着いたら徐々に台数を減らしていっています。

 クックパッドもサービスとしてはピーキーなアクセスが来るサービスで、通常だと夕方の買い物前のアクセスが一番多いです。 ECS での Auto Scaling 設定当初は 5XX エラーを出さず、scale-out が間に合うように細かい調整を行っていました。

イベント型サービスと Auto Scaling

 ライブ配信の様にコンテンツの視聴出来る時間が決まっていて特定の時間にユーザが一気に集まるサービスでは、負荷に応じた scale-out では間に合わないことがあります。 立ち上げ当初はユーザ数もそこまで多くなかったということもあり、前述の負荷に応じた Auto Scaling 設定しか入れていませんでした。 その為、一度想定以上のユーザ数が来たときに scale-out が間に合わずライブ配信開始を 40 分ほど遅らせてしまいました。 基本的に cookpadTV のライブ配信の番組は週一で放送されていますので、1 週間以内に対応しないと同じ番組でまたユーザに迷惑を掛けてしまいます。 そこで cookpadTV チームではこの問題を最優先対応事項として改善を行いました。

まずはパフォーマンス・チューニング

 Auto Scaling の仕組みを改善する前にまずは API 側のコードを修正することで対応を行いました。 こういう細かい処理のロジックを見直すことで最適化、高速化することももちろん効果的ですが、ピーキーなアクセスで最も効果的なのは短期キャッシュです。 キャッシュは適切に使わないと思わぬトラブルを生むことが多く、出来れば使用は避けたいものです。 しかし短期キャッシュであれば情報の更新頻度次第ではあまり問題にならない場合があります。 そして今回のケースにおいてはライブ配信の時間は事前に決定し変更はされない、レシピ情報も事前に確定することがほとんです。 その為、短期キャッシュを入れる事でキャッシュのデメリット無くアプリケーションのスループットを上げることが出来ると判断しました。

 想定外のユーザ数が来た時に一番問題になったのは cookpadTV の更に先にある別の Micro Service(サービス B) への API リクエストでした。 サービス B は Auto Scaling 設定が入っておらず、大量にアクセスが流れてレスポンスタイムが悪化、cookpadTV API の unicorn worker が詰まって Nginx がエラーを返し始めるという状況でした。 ここの部分の API コールを短期キャッシュすることでスループットを大幅に上げることが出来ました。

f:id:wata_htn:20180426204103p:plain

Auto Scaling の改善

 さて、アプリケーション自体の改善は 1 週間以内で出来ることはやりました。 次は Auto Scaling 側の改善です。 冒頭でも記載した通り、負荷が上がった後では間に合わないのでライブ配信が始まる前に scale-out を済ませておく必要があります。 傾向からしてもライブ配信開始 15 分前ぐらいから徐々に人が来はじめ、直前ぐらいに push 通知を受けてユーザが一気に来ます。 その為、何かあったときに備え、余裕を持って 15 分前には scale-out を済ませておきたいと考えました。

クックパッドでの ECS Service の Auto Scaling

 クックパッドでは ECS Services の desired_count、pending_count、running_count を定期的にチェックして、pending_count を解消できるように EC2 インスタンスが scale-out されるようになっています。 その為 desired_count を何かしらの仕組みで増やすことが出来れば、後は EC2 インスタンスも含め scale-out されていきます。

単純に desired_count を増やせば良いわけではない

 ただし、単純にライブが始まる 30 分前に desired_count を増やすだけではまだ負荷が高くない為、徐々に scale-in されてしまいます。 さらに、API サーバは全配信で共有のため、複数番組同時に放送されると時間帯によっては単純に「番組配信前に指定 task 数増やす」だけではうまく行きません。 事前に scale-out したのが配信前に scale-in してしまっては意味が無いので、desired_count を単純に上げるのではなく min_capacity をライブ開始 30 分前に指定し、ライブ開始時間に min_capacity を元に戻す方式を採用しました。 この時限式に min_capacity を調整するのは、Aws::ApplicationAutoScaling::Client#put_scheduled_actionを使用して実現しています。

 コードとしては以下のような形です。

defschedule_action(episode)
  scale_out_time = [episode.starts_at - 30.minutes, 1.minute.after].max
  scale_in_time = episode.starts_at
  aas.put_scheduled_action({
    service_namespace: "ecs",
    schedule: "at(#{scale_out_time.getutc.strftime('%FT%H:%M:%S')})", # UTC で指定する必要がありますscheduled_action_name: "EpisodeScaleOut##{episode.id}",
    resource_id: "service/xxxx/cookpad-tv-api",
    scalable_dimension: "ecs:service:DesiredCount",
    scalable_target_action: {
      min_capacity: reserved_desired_count(from: scale_out_time), # ここで scale-out するための min_capacity を計算
    },
  })
  aas.put_scheduled_action({
    service_namespace: "ecs",
    schedule: "at(#{scale_in_time.getutc.strftime('%FT%H:%M:%S')})", # UTC で指定する必要がありますscheduled_action_name: "EpisodeScaleIn##{episode.id}",
    resource_id: "service/xxxx/cookpad-tv-api",
    scalable_dimension: "ecs:service:DesiredCount",
    scalable_target_action: {
      min_capacity: reserved_desired_count(from: scale_in_time + 1.second), # ここで scale-in するための min_capacity を計算
    },
  })
enddefaas@aas ||= Aws::ApplicationAutoScaling::Client.new
end

 やっている事を図で表すと以下の通りです。

f:id:wata_htn:20180426204251p:plain

 min_capacity が引き上げられると結果的に desired_count も引き上げられ、running task 数が増えます。 上の図の結果、running task 数は以下の図の赤線の推移をします。

f:id:wata_htn:20180426204317p:plain

 そして、ライブ配信が被った時間帯に配信されることも考慮して、重複した時には必要数を合計した値で min_capacity をコントロールするようにしました。 勿論 min_capacity を戻す時も被っている時間帯の配信も考慮して計算しています。 先程の図に番組 B が被った時間に配信されるとすると以下の様になります。

f:id:wata_htn:20180426204341p:plain

running task 数で表現すると以下となります。

f:id:wata_htn:20180426204406p:plain

 そして、番組毎に盛り上がりが違うので、それぞれ違う task 数が必要です。 そこで番組毎の必要 task 数を、以前の近しい時間帯に配信された番組の視聴ユーザ数から割り出すようにしました。 前回とかでなく「近しい時間」としているのは、番組によっては週によって配信時間が変わったりし、そして平日だとお昼よりも夜の方が来場数が多かったりするからです。

以前の配信のユーザ数等のデータ処理

 さらっと「以前の近しい時間帯に配信された番組の視聴ユーザ数」と書きましたがこれは以下のように利用できるようにしています。

  1. ユーザの視聴ログから bricolageでサマリを作成(Redshift -> Redshift)
  2. redshift-connector + Queuery を使って MySQL にロード

 クックパッドでは全てのログデータは Amazon Redshiftに取り込まれるようになっていて、そのデータを Tableauを使って可視化しています。 それをデータ活用基盤を利用して加工、アプリケーションの MySQL まで取り込んでいます。

 後は番組情報が作成、更新されたらその付近で配信予定の番組も合わせて min_capacity が再計算されるようになっています。 これらによって予約された Auto Scaling を管理画面からも確認出来るようにしました。

f:id:wata_htn:20180426204438p:plain※画像はイメージです

初回と突発的な対応

 以前の配信がある場合はこれで対応出来ますが、初回や突発ケースにはこれだけでは不十分です。 初回はさすがに読めない部分が多いのですが、SNS での拡散状況等や、番組のお気に入り数等から来場ユーザ数を人が推測し予め設定出来るようにしました。 来場ユーザ数が指定されていると、それに必要な task 数を計算し、上記の流れと同じ様に Auto Scaling が行われます。 なかなか完全にはシステム化は難しく、こういう人のが介在する余地はまだまだ必要だったりします。 (勿論 SNS の情報を引っ張ってきて、人の予測ロジックをアルゴリズム化しても良いのですが)

退出

 忘れていけない事として、ライブ配信開始時の突入だけでなく退出があります。 ライブ視聴を終わったユーザが一気にアプリの他の画面に移動するため、ライブ開始時と同じぐらいの負荷が来ます。 ここをクリアするために scale-in はゆっくり目にして、一気に task 数が減らないようにして対処しています。 ライブ配信の終了時間は読めないため、ここは予定を組んで置くことが出来ないためです。

 参考までに突入と退出の負荷がどれくらいなのかリソース利用量のグラフを貼っておきます。

f:id:wata_htn:20180426204505p:plain

今後の発展

 同時アクセスが大量に来るのは push 通知によっても発生することがあります。その為、今後は remote push 通知を送信する前に送信件数をベースに Auto Scaling する仕組みを導入していく想定です。

補足

 Micro Services でサービスを開発していると、他チームの Service に依存して、自分達の Service だけ Auto Scaling してもサービス全体としては成り立たないことがあります。 その為その境界線を意識し、自分達の開発しているサービス内でカバーできるような設計にしていく必要があります。 キャッシュやリトライ戦略は各 Service が個別に開発するというよりはサービスメッシュ*2によって統合管理が達成出来るのではと考えています。

最後に

 イベント型サービス向けの Auto Scaling が必要になってから、突貫で作った形ではありますがクックパッドの既存の基盤のおかげでなんとか運用が回る Auto Scaling 環境が出来ました。 この辺りの基盤がしっかりしている事は今回非常に助かりました。

 さて、今回の事例紹介は以上ですが自分だったら、もっと改善出来る!という方で一緒にやっていきたいと思ってくださった方は是非私までお声がけください。

S3に保存したログファイルをストリーム処理するサーバーレスアプリケーションの紹介

$
0
0

インフラストラクチャー部セキュリティグループの水谷(@m_mizutani)です。

クックパッドでは現在セキュリティ監視の高度化に取り組んでおり、その一環としてセキュリティ関連のログ収集およびその分析に力を入れています。ログ収集の部分では可用性などの観点からAWSのオブジェクトストレージサービスであるS3に一部のサービスやサーバのログをまず保存し、後から保存されたファイルを読み込んで分析などに利用しています。分析のためにS3に保存したファイルを前処理する方法としてAWS Glueなどを用いたバッチ処理がありますが、到着したログをなるべくストリームデータのように扱いたい場合もあります。特にセキュリティ関連のログでは以下のようなユースケースで利用しています。

  • アラートの検出: ログを検査してその中から危険度の高いと考えられるログを探し出し、アラートとして発報します。アラートの具体的な例としてはオフィス環境からの不審な通信やスタッフ用クラウドサービスでの不審な活動、スタッフPC上の不審イベントなどが挙げられ、ログの各種パラメータをチェックして危険度を判定します。
  • ログの変換と転送: S3に蓄積されたログの形式を変換して別のデータソースへ転送します。具体的にはGraylogに転送してログ分析をしやすいようにしたり、Athenaによって検索できるように変換して別のS3バケットに配置するといったことをしています。

こうした処理はバッチで対応できないこともありませんが、例えばセキュリティ分析の文脈においては、2, 3分程度の遅延であれば許容できても、1時間単位の遅れが発生するのは少し困ります。できる限り到着したものから順次処理して分析までのレイテンシを短くしたいところです。本記事ではこのような処理に対し、S3に保存したログファイルをなるべくレイテンシが短くなるように処理するためのアーキテクチャ、そしてそのアーキテクチャのためのフレームワークを紹介したいと思います。

最もシンプルなS3オブジェクトの処理構成とその課題

AWSの環境において、S3に到着したログを処理するのに最もシンプルな構成はどのようなものでしょうか? おそらく下の図のように、S3にオブジェクト(ファイル)が生成されたというイベントによってサーバーレスコンピューティングサービスであるAWS Lambdaが起動し、その後起動したLambdaがオブジェクトそのものをダウンロードして処理する、という構成かと思います。

f:id:mztnex:20180502110255p:plain

この構成はシンプルで構築しやすいのですが、実際に運用してみるといくつかの課題が見えてきました。

  • S3のObjectCreatedイベントの送信は、1つのS3バケットにつき1つの宛先にしか設定できない: 単独のサービスしか動かしていない場合は直接Lambdaを起動するだけで事足ります。しかし、S3にオブジェクトが生成されたタイミングで何か処理をしたいという要求が2つ以上でてくると、直接Lambdaを起動するのではなく別の方法を考えないとなりません。
  • 流量制限が難しい: 例えばS3に保存されたオブジェクトを変換して別にデータストアに投入するというタスクでは、投入する先のデータストアに対する流量を気遣う必要があります。データストアの種類にもよりますが流量を超えすぎると全体のパフォーマンスが低下したり、データをロスするといったことが起きる可能性があります。当然、予想される流量に対して受け側のキャパシティを確保してくのは重要なのですが、ログの種類によっては一過性のバーストがしばしば起こります。S3から直接Lambdaを起動しているとこういった任意のタイミングで外部から流量をコントロールするのは難しいです。AWSの機能でLambdaの同時実行数を制限してスロットリングさせる方法もありますが、処理順序が時系列と大幅にずれたり複数回失敗して自前で再試行しないとならないといったところで煩雑になってしまいます。
  • エラーのリトライが大変: システムやサービスを常に更新・拡張していると実運用におけるエラーの発生は避けられません。よってエラーが起きたリクエストに対して再試行するという場面はたびたびあるのですが、これをなるべく簡単にやりたいという思いがあります。例えばエラーの発生はCloudWatch Logsに実行時ログとして残すことができますが、実行時ログの量が多くなるとそもそも検索が難しくなるなどの問題があります。また、どのエラーに対して再試行したのか・していないのかを手動で管理することになり、煩雑になってしまいます。

実際に使われているサーバーレスアプリケーションの構成図

f:id:mztnex:20180502110311p:plain

いくつかの課題を解決してスムーズな運用を試行錯誤した結果、上記の図のような構成に落ち着きました。シンプルな構成に比べるとそれなりに複雑なサーバーレスアプリケーションに仕上がりましたが、課題となっていた部分を解決しています。S3にオブジェクトが作成されてからどのように処理されるかというワークフローを、順を追って以下で説明します。

  1. S3のイベントはLambdaで直で受けるのではなくSNSで配信するようにしています。これによって複数の処理を実行したい場合でもSNSからイベントを受け取ることができるようになります。
  2. S3のObjectCreatedイベントを受けた EventPusherというLambdaが一度これをKinesis Streamにイベントを流します。
    • Kinesis Streamは一度データを投入すると指定時間(デフォルトで24時間)保持されるので、これ以降の処理を一時的に止めなければいけなくなってもイベントを蓄積し、あとから自由に読み出すことが可能です。
    • Kinesis Streamには Fast-laneSlow-laneという2つを用意しています。前者が高優先、後者が低優先にS3オブジェクトを処理するもので、EventPusherが送られてきたイベントの中身に応じてどちらかに振り分けます。それぞれのStreamに設定の違いはありませんが、この先にあるDispatcherの役割が異なります。
  3. それぞれKinesis Streamからイベントを受け取ったFastDispatcher と SlowDispatcherMainを非同期で呼び出します。Mainはシンプルな構成のLambdaに該当し、最終的にユーザがやらせたい処理を請け負います
    • 便宜上、 FastDispatcherSlowDispatcherと名前をつけていますが、構成に違いはありません。そのかわり、 EventPusherでなるべく遅延なく処理したいと思うものを Fast-laneからの FastDispatcher、 多少遅延してもいいものを Slow-laneからの SlowDispatcherにそれぞれ振り分けます
    • 各 Dispatcher には DELAYという環境変数が設定されており、これに整数値 Nをセットすることで Mainを呼び出した後に指定した N秒 sleep した後に Dispatcher が終了します。Kinesis StreamからはLambdaは直列&同期的に呼び出されるため、時間単位で呼び出される回数が抑制され、全体の流量が抑制されます。基本的には SlowDispatcherの遅延を増やすことを想定しています。
    • 特に流量制限はこのサーバーレスアプリケーションの外部のメトリック値(例えばDBに実際に投入されるデータの流量など)を参照するためこの図には流量制御の仕組みはでてきませんが、例として定期的にメトリック値をチェックしたりCloudWatch Alarmのような仕組みで遅延を調整するという方法が考えられます。
  4. 起動した Mainはイベント情報として作成されたS3オブジェクトのバケット名とキーが引き渡されるので、実際にS3からオブジェクトをダウンロードして必要な処理をします。
  5. Mainの処理が適切に終了しなかった場合、2回まで再試行されますが、それでも正常終了しなかった場合はDLQにエラーの情報が引き渡されます。ここでは実装上の都合などからSNSを使っています。
  6. DLQ(SNS)経由で Reporterが呼び出され Lambda の requset_id と呼び出し時の引数(EventPusherから射出されたイベント)が引き渡されます
  7. Reporterは渡されたエラーの内容を粛々と ErrorTaskTableに投入します。ここまでで自動的に処理されるデータフローは一度終了します。

これ以降は保存されたエラーをどのように対応するか、というワークフローになります。

  1. 任意のタイミングでユーザが Drainを起動します。瞬間的なバーストや不安定なリソースによる失敗は (5) の2回までの再試行である程度解消されると期待されますが、それ以外のコード由来のエラーなどは何度試行しても同じ結果になるためループさせるとシステム全体の負荷になってしまします。なので、ここまでエラーで残ったものについてはユーザが確認してから再度処理のワークフローに戻すのが望ましいため手動で発火させる仕様にしています。
  2. 起動した DrainErrorTaskTableからエラーを全て、あるいは選択的に吸い出してます。
  3. Drainは吸い出したエラーになったイベントを再度Kinesis Streamに放流します。この時送信するイベントは EventPusherが作成したものですが、 EventPusherはKinesis Stream投入時にどちらのstreamに投入したかの情報を付与しているため、もともとのstreamに戻されます。

このようなワークフローで処理することで、エラーが起きた場合でも少ない手間で修正・再実行し開発と運用のサイクルを回していくことができます。

実装

上記のサーバーレスアプリケーションを展開するために slipsというフレームワークを実装して利用しています。このフレームワークはPythonのライブラリとして動作し、導入したプロジェクトを先程のサーバーレスアプリケーションの構成で動かすための大部分をサポートしてくれます。サーバーレスアプリケーションを作成する時に苦労するポイントの1つにコンポーネントの設定や各コンポーネント同士のつなぎ込みといった作業があるかと思います。slipsは「S3からログファイルを読み込んで順次処理をする」というタスクを実行するサーバーレスアプリケーション作成におけるコンポーネントの設定、および統合をサポートしてくれます。これによって開発者がサーバーレスアプリケーションのコンポーネントを構成する際の消耗を最小限におさえ、ログデータを処理する部分の開発に集中できます。

slipsの実態としてはCloudFormationのラッパーになっています。サーバーレスアプリケーションのコンポーネントを制御するフレームワークとしては他にもAWS Server Application ModelServerless Frameworkが挙げられますが、ここの部分を自作したのは以下のような理由によります。

  • 構成の共通化: 冒頭で複数の処理に利用したいと書きましたが、最終的に読み取ったログの処理する部分以外はほとんどが共通した構成のサーバーレスアプリケーションになります。また、構成そのものをアップデートしてよりよい処理に変えていく必要があるため、複数のほぼ同じ構成を管理するのは冗長でメンテナンスの負荷が大きくなってしまいます。そのため、大部分の構成の定義を自動的に生成してCloudFormationで展開するというアプローチになっています。
  • 環境による差分の調整: ほとんどが同じ構成と上述しましたが、一方で環境ごとに微妙な構成の差もあるためこれを吸収する必要があります。例えばある環境ではコンポーネントを自動生成できるが、別の環境では専用の承認フローに基づいて作成されたコンポーネントを利用する必要がある、といった状況を吸収する必要があります。既存フレームワーク内にこういった環境ごとの差分を簡単なロジックで書き込むことは可能ですが、数が多くなったりロジックが複雑になったりすると管理が難しくなるので、割り切ってPythonのコードとしてロジックを作成しています。

このフレームワークを利用することで、先程のサーバーレスアプリケーションにでてきたAWS上の構成をほぼ自動で展開してくれます。先程のアーキテクチャの図を元に説明すると以下のような構成になります。

f:id:mztnex:20180502110328p:plain

S3へのログ保存とSNSへの通知設定、そしてS3に蓄えられたログをパースした後の処理(例えば、形式を変換したり、どこかへ転送したり、ログの内容を検査したりなど)を実施するためのコードは自前で用意する必要がありますが、それ以外の部分のコンポーネントをCloudFormationを利用してシュッと構築してくれます。これを利用することで、S3のログで何か処理をしたいユーザ(開発者)はS3に保存されたログが途中どのように扱われるかを意識することなく、最終的な処理の部分に集中することができるようになります。

導入と設定ファイルの作成

まずPythonのプロジェクト用ディレクトリを作成します。適当に作成したディレクトリに入って virtualenv などで環境を作成し、pipenv で slips を導入します。

$ virtualenv venv
$ source venv/bin/activate
$ pipenv install -e 'git+https://github.com/m-mizutani/slips.git#egg=slips'

次に設定ファイルを作成します。ファイル名を config.ymlとします。サンプルはここにありますが、本記事では要点のみ解説します。

backend:
  role_arn:
    event_pusher: arn:aws:iam::1234xxxxxxx:role/LambdaSlamProcessorEventPusher
  sns_topics:
    - name: SecLogUplaod
      arn: arn:aws:sns:ap-northeast-1:1234xxxxxx:seclog-event

上記は構築するサーバーレスアプリケーションの各要素の設定を記述します。ObjectCratedのイベントが流れてくるSNSトピックの指定は必要ですが、 role_arnの項では既存のIAMロールを割り当てることもできますし、省略すると必要なIAMロールをCloudFormationで自動的に追加します。同様にKinesis StreamやDLQに使われるSNSも、ARNを指定すれば既存のものが利用され、指定しなければ必要なものが自動的に生成されます。

これは組織や環境によって各リソースの管理方法が異なるため、別途作成されたリソースを使う場合とCloudFormationで自動的に生成する場合を切り替えることができるようにしています。例えばクックパッドでは本番環境のIAM権限などいくつかのリソースはGithub Enterprise上のレポジトリで厳密に管理されていますが、開発環境では比較的自由にリソースを作成できるようになっています。このような環境にあわせて容易に展開できるような実装になっています。

handler:
  path: src/handler.py
  args:
    sample

上記の設定は自分が実行させたい処理についての定義をしています。pathで実行させたいファイルのパスを指定し、argsで実行時に渡したい引数の内容を指定します。

bucket_mapping:
  mizutani-test:
    - prefix: logs/azure_ad/signinEvents/
      format: [s3-lines, json, azure-ad-event]
    - prefix: logs/g_suite/
      format: [s3-lines, json, g-suite-login]

上記のパートでどのS3バケットにどのようなフォーマットのオブジェクトがアップロードされるかを指定します。これを記述しておくことで、自分で書くスクリプトはパース済みのデータを受け取れるようになります。backet_mapping以下のキーがS3バケット名、その下のリストでS3キーのプレフィックスとパースするためのフォーマットを指定します。上記例にでてくる指定は以下のような意味になります。

  • s3-lines: S3のオブジェクトをテキストファイルとして1行ずつファイルを読み込む
  • json: 1行をJSON形式としてパースする
  • azure-ad-event: AzureADのログ形式だと仮定して、タグ付けしたりタイムスタンプを取得する

コードの準備

設定の準備ができたら、自分が処理したいコードを用意します。デフォルトだと ./src/以下においたコードがzipファイルにアーカイブされてAWS上にアップロードされるので、今回は ./src/handler.pyというファイルを作成します。サンプルとしてログの件数をCloudWatchのカスタムメトリクスに送信するコードを書いてみます。

import boto3
import slips.interface

classLogCounter(slips.interface.Handler):
    defsetup(self, args):
        self._namespace = args['namespace'],
        self._count = 0defrecv(self, meta, event):
        self._count += 1defresult(self):
        metric = {
            'MetricName': 'LogCount',
            'Value': float(self._count),
            'Unit': 'Count',
        }

        cloudwatch = boto3.client('cloudwatch')
        cloudwatch.put_metric_data(Namespace=self._namespace, MetricData=[metric])

        return'ok'# Return some value if you need.

slips.interfaceモジュールに含まれている slips.interface.Handlerクラスを継承してクラスを定義すると、これが読み出されて実行されます。新しく作成するクラスには以下のメソッドをそれぞれ定義します。

  • def setup(self, args): 最初に1度だけ呼び出されます。argsには handler設定項目内の argsで指定した構造データが渡されます
  • def recv(self, meta, event): パースしたログ1件に対して1度呼び出されます。metaはパースする過程で得られた付加情報が格納されています。eventは辞書型のパース済みログデータが渡されます
  • def result(self): 終了時に呼び出されます。returnで値を返してテストなど利用します

デプロイ

以上の準備ができたらAWSの環境にデプロイできます。ディレクトリには以下のようになっているかと思います。参考までに、サンプルの構成をgithubにあげておきます。

$ tree
.
├── Pipfile
├── Pipfile.lock
├── README.md
├── config.yml
├── src
│   └── handler.py
└── venv
    ├── bin
(省略)

この状態で slipsdeployコマンドを実行します。実行にはCLI用のAWSのCredential情報が必要です(詳しくはこちら)以下が実行結果になります。

$ slips -c config.yml deploy
2018-04-13 14:40:07.379 INFO [cli.py:466] Bulding stack: log-counter
2018-04-13 14:40:07.379 INFO [cli.py:154] no package file is given, building
2018-04-13 14:40:07.736 INFO [cli.py:470] package file: /var/folders/3_/nv_wpjw173vgvd3ct4vzjp2r0000gp/T/tmpem2eyhgf.zip
2018-04-13 14:40:07.736 INFO [cli.py:474] no SAM template file is given, building
2018-04-13 14:40:07.763 INFO [cli.py:481] SAM template file: /var/folders/3_/nv_wpjw173vgvd3ct4vzjp2r0000gp/T/tmp1bahl7dn.yml
2018-04-13 14:40:07.763 INFO [cli.py:418] package command: aws cloudformation package --template-file /var/folders/3_/nv_wpjw173vgvd3ct4vzjp2r0000gp/T/tmp1bahl7dn.yml --s3-bucket home-network.mgmt --output-template-file /var/folders/3_/nv_wpjw173vgvd3ct4vzjp2r0000gp/T/tmpa1r9z4e7.yml
2018-04-13 14:40:08.652 INFO [cli.py:431] generated SAM file: /var/folders/3_/nv_wpjw173vgvd3ct4vzjp2r0000gp/T/tmpa1r9z4e7.yml
2018-04-13 14:42:11.138 INFO [cli.py:461] Completed (Applied)
2018-04-13 14:42:11.138 INFO [cli.py:559] exit

これがうまくいくとバックグラウンドでCloudFormationが実行され、対象のアカウントに必要なリソースがバーンと展開されます。同時に、CloudWatchのダッシュボードも自動生成されるので、それを見るとこのサーバーレスアプリケーションの実行状況がある程度把握できます。

f:id:mztnex:20180502110345p:plain

まとめ

AWS Lambdaや各種マネージドサービスを使ったサーバーレスアプリケーションは便利に利用できる反面、実行の制御やエラー処理などの勝手がインスタンス上とは異なり、実行の制御やエラー処理をスムーズに実施できるような構成にする必要があります。これをサポートする機能やサービスもAWSには充実しており、クックパッドでは日々それらを活用しながら改善に取り組んでいます。

今回はS3からログを順次読み取って処理するサーバーレスアプリケーションの構成と、それを補助するためのフレームワーク slipsを紹介しました。今後、新たにサーバーレスアプリケーションを構築する方の参考になれば幸いです。

Cookpad Spring 1day Internship 2018 を開催しました

$
0
0

技術広報を担当している外村です。

今年クックパッドでは、2月から3月にかけて、一日で最新の技術を学ぶインターンシップを以下の4コース開催しました。

  • サービス開発コース
  • インフラストラクチャーコース
  • Rustプログラミングコース
  • 超絶技巧プログラミングコース

Cookpad Spring 1day Internship 2018

たくさんの学生に参加していただき、真剣に課題に取り組んでくれました。参加していただいが学生のみなさん、ありがとうございました。

それぞれのコースの内容について簡単に紹介します。

サービス開発コース

サービス開発コースはクックパッドのサービス開発プロセスを一日で学ぶという内容のコースです。「大学生のおでかけの課題を解決するスマホアプリ」をテーマに、エンジニアとデザイナーがペアになって課題解決のためのサービスを考えてもらるという内容でした。

ユーザーインタビューやペルソナの作成から始まり、価値仮説やユーザーストーリを考えて最終的にペーパープロトタイピングを作ってもらいました。全チームがプロトタイプを作成するところまでやり終え、クックパッドのサービス開発プロセスを体感してもらうことができました。

インフラストラクチャーコース

インフラストラクチャーコースでは、AWS を使った Web アプリケーションインフラ構築を行いました。小さな Rails アプリケーションを題材に、サービスの成長に沿って必要になってくる要素について解説し、それを構築して使う、というものです。

実際の流れは以下のようなものでした。

  • まずは小さい EC2 インスタンスにとりあえずアプリケーションをデプロイ
  • Itamae を使ってインスタンスをセットアップしてみる
  • systemd を触ってみる
  • Rack サーバを production 向きのものに変更する
  • コマンドやプロファイラを使ってパフォーマンスモニタリングをする
  • RDBMS のチューニングをする (インデックスの作成や N+1 クエリへの対処)
  • Web サーバ (nginx) のチューニングをする
  • DB を Amazon RDS に分離し、アプリケーションサーバをスケールアウトする
  • ロードバランサを導入する
  • memcached などのキャッシュを導入する

構築しているアプリケーションは常にベンチマーカーによってチェックされており、作業によるパフォーマンスアップを実際に感じることができるようになっていました。

「なかなかインフラに関わることってなくて...」という方に多くご参加いただけたので、なかなか新鮮な体験をしていただけたようです。

Rustプログラミングコース

Rust プログラミングコースでは、プログラミング言語 Rust を使ってリバーシゲームを作りました。

午前パートでは Rust の鬼門と言われる Ownership、 Borrowing、 Lifetime という概念の解説をしました。いかにして Rust が GC なしで安全なメモリ管理を実現しているのかについて、スクリプト言語の経験しかない受講者にも理解できるよう、ヒープとスタックの違いなどといったプロセスのメモリモデルの説明から紐解いていきました。

午後のパートでは実際にコードを書き、講師が用意したコードの穴を埋める形でリバーシゲームを実装していきました。実際に遊べるゲームが完成することで、受講者は達成感を感じられていたようです。早めに完動させることに成功した受講者は各々自由な改造をして楽しんでいました。

講義で利用したコードは以下になります。

KOBA789/rust-reversi

超絶技巧プログラミングコース

超絶技巧プログラミングコースは、あなたの知らない超絶技巧プログラミングの世界の著者で、Rubyコミッタでもある遠藤を講師として、技巧を駆使した実用性のないプログラムを作成する手法を学ぶという内容の講義でした。

前半の講義ではプログラムをアスキーアートにする方法や、Quine(自分自身を出力するプログラム)を書く方法について学び、後半は実習として各自に超絶技巧プログラム書いてもらいました。テトリスや将棋のQuineなど、皆さん超絶技巧を使って面白い成果物を作成されていました。

Summer Internshipのご案内

Cookpad Summer Internship 2018

クックパッドでは、毎年恒例になっているサマーインターンシップを今年も開催いたします!今年のインターンシップは、サービス開発エンジニア向けに 10 Day Tech インターンシップ、リサーチエンジニア向けに 5 Day R&D インターンシップ、デザイナー向けに 5 Day Design インターンシップを行なうというかたちになっています。

10 Day Tech インターンシップでは、モバイルアプリケーションからサーバーサイド、インフラストラクチャーまで、フルスタックエンジニアとして必要な技術をぎゅっと前半の講義・課題パートに詰め込んでいます。後半では、クックパッドの現場に配属されて研修を行なうOJTコースと、前半で学んだ内容をチームで実践するPBLコースに分かれてインターンシップを行ないます。

5 Day R&D インターンシップは、機械学習や自然言語処理を専攻とする修士課程・博士課程の方を対象に実施します。クックパッドの膨大なデータという大学の研究では経験することが難しい生のデータに触れることのできる、貴重な機会です。

また、5 Day Designは、参加者同士でチームを組み、料理に関する課題を解決するサービスを発想し形にします。メンターからサポートを受けながら、ユーザーが抱える課題をインタビューを通して理解し、その解決策のデザイン・プロトタイピングを行ないます。実践と平行して、クックパッドが持つサービスデザイン手法やノウハウを講義形式で学びます。

皆さまのご応募をお待ちしております!

Service Mesh and Cookpad

$
0
0

こんにちは、開発基盤の Taiki です。今回は、マイクロサービスで必須のコンポーネントとなりつつあるサービスメッシュについて、クックパッドで構築・運用して得られた知見についてご紹介できればと思います。

サービスメッシュそのものについては以下の記事や発表、チュートリアルで全体感をつかめると思います:

目的

クックパッドでは主に障害対応やキャパシティプランニング、Fault Isolation に関わる設定値の管理といった、運用面での課題を解決すべくサービスメッシュを導入しました。具体的には:

  1. サービス群の管理コストの削減
  2. Observability*1*2の向上
  3. より良い Fault Isolation 機構の構築

1つ目については、どのサービスとどのサービスが通信していて、あるサービスの障害がどこに伝播するのか、ということを規模の拡大とともに把握しづらくなってるという問題がありました。どことどこが繋がっているかの情報を一元管理することでこの問題は解決できるはず、と考えました。

2つ目については (1) をさらに掘ったもので、あるサービスと別のサービスの通信の状況がわからないという課題でした。例えば RPS やレスポンスタイム、成功・失敗ステータスの数、タイムアウトやサーキットブレーカーの発動状況などなど。あるバックエンドサービスを2つ以上のサービスが参照しているケースでは、アクセス元のサービス毎のメトリクスではないため、バックエンドサービス側のプロキシーやロードバランサーのメトリクスでは解像度が不十分でした。

3つ目については、「Fault Isolation がうまく設定できていない」という課題でした。当時はそれぞれのアプリケーションでライブラリを利用して、タイムアウト・リトライ・サーキットブレーカーの設定を行っていましたが、どんな設定になっているかはアプリケーションコードを別個に見る必要があり、一覧性がなく状況把握や改善がしづらい状況でした。また Fault Isolation に関する設定は継続的に改善していくものなので、テスト可能であったほうが良く、そのような基盤を求めていました。

さらにもっと進んだ課題解決として、gRPC インフラの構築、分散トレーシング周りの処理の委譲、トラフィックコントロールによるデプロイ手法の多様化、認証認可ゲートウェイなどのような機能もスコープに入れて構築しています。このあたりについては後述します。

現状

クックパッドでのサービスメッシュは data-plane として Envoy を採用し、control-plane は自作、という構成を選択をしました。すでにサービスメッシュとして実装されている Istio を導入することも当初は検討したのですが、クックパッドではほぼ全てのアプリケーションが AWS の ECS というコンテナ管理サービスの上で動作している関係上 Kubernetes との連携メリットが得られないことと、当初実現したいことと Istio のソフトウェア自体の複雑さを考慮して、小さく始められる自作 control-plane という道を選びました。

今回実装したサービスメッシュの control-plane 部分はいくつかのコンポーネントによって構成されています。各コンポーネントの役割と動作の流れを説明します:

  • サービスメッシュの設定を中央管理するリポジトリ
  • kumonos*3という gem を用いて上記の設定ファイルから Envoy xDS API*4用のレスポンス JSON を生成
  • 生成したレスポンス JSON を Amazon S3 上に配置して Envoy から xDS API として利用する

中央のリポジトリで設定を管理している理由は、

  • 変更履歴を理由付きで管理して後から追えるようにしておきたい
  • 設定の変更を SRE 等の組織横断的なチームもレビューできるようにしておきたい

という2点です。

ロードバランシングについては、基本的には Internal ELB に任せるという方式で設計したのですが、gRPC アプリケーション用のインフラ整備も途中から要件に入った*5ので、SDS (Service Discovery Service) API を用意して client-side load balancing できるようにしています*6。app コンテナに対するヘルスチェックを行い SDS API に接続先情報を登録する side-car コンテナを ECS タスク内にデプロイしています。

f:id:aladhi:20180501141121p:plain

メトリクス周りは次のように構成しています:

  • メトリクスは全て Prometheus に保存する
  • dog_statsd sink*7を使用してタグ付きメトリクスを ECS コンテナホスト(EC2 インスタンス)上の statsd_exporter*8に送信
    • 固定タグ設定*9を利用してノード区別のためにアプリケーション名をタグに含めています
  • Prometheus からは EC2 SD*10を利用して statsd_exporter のメトリクスを pull
    • ポート管理のために exporter_proxy*11を間に置いています
  • Grafana、Vizceral*12で可視化

ECS, Docker を利用せずに EC2 インスタンス上で直接アプリケーションプロセス動かしている場合は、Envoy プロセスも直接インスタンス内のデーモンとして動かしていますが、構成としてはほぼ同じです。直接 Prometheus から Envoy に対して pull を設定していないのは理由があり、まだ Envoy の Prometheus 互換エンドポイントからは histogram メトリクスを引き出せないからです*13。これは今後改善される予定なのでその時は stasd_exporter を廃止する予定です。

f:id:aladhi:20180502132413p:plain

Grafana 上ではサービス毎に各 upstream の RPS やタイムアウト発生等の状況が見れるダッシュボードと Envoy 全体のダッシュボードを用意しています。サービス×サービスの粒度のダッシュボードも用意する予定です。

サービス毎のダッシュボード

f:id:aladhi:20180501175232p:plain

Upstream 障害時のサーキットブレーカー関連のメトリクス

f:id:aladhi:20180502144146p:plain

Envoy 全体のダッシュボード

f:id:aladhi:20180501175222p:plain

サービス構成は Netflix が開発している Vizceral を利用して可視化しています。実装には promviz*14と promviz-front*15を fork して開発したのもの*16を利用しています。まだ一部のサービスにのみ導入しているので現在表示されているノード数は少なめですが、次のようなダッシュボードが見れるようにしています:

リージョン毎のサービス構成図と RPS、エラーレート

f:id:aladhi:20180501175213p:plain

特定のサービスの downstream/upstream

f:id:aladhi:20180501175217p:plain

またサービスメッシュのサブシステムとして、開発者の手元から staging 環境の gRPC サーバーアプリケーションにアクセスするためのゲートウェイをデプロイしています*17。これは hako-console という社内のアプリケーションを管理しているソフトウェア*18と SDS API と Envoy を組み合わせて構築しています。

  • Gateway app (Envoy) が gateway controller に xDS API リクエストを送る
  • Gateway controller は hako-console から staging 環境かつ gRPC アプリケーションの一覧を取得して、それを基に Route Discovery Service/Cluster Discovery Service API レスポンスを返す
  • Gateway app はレスポンスを基に SDS API から実際の接続先を取得する
  • 開発者の手元からは AWS ELB の Network Load Balancer を参照し Gateway app がルーティングを行う

f:id:aladhi:20180502132905p:plain

効果

サービスメッシュの導入で最も顕著だったのが一時的な障害の影響を抑えることができた点です。トラフィックの多いサービス同士の連携部分が複数あり、今までそれらでは1時間に5,6件ほどのネットワーク起因の trivial なエラーが恒常的に発生していた*19のですが、それらがサービスメッシュによる適切なリトライ設定によって1週間に1件出るか出ないか程度に下がりました。

モニタリングの面では様々なメトリクスが見れるようになってきましたが、一部のサービスのみに導入していることと導入から日が浅く本格的な活用には至ってないので今後の活用を期待しています。管理の面では、サービス同士の繋がりが可視化されると大変わかりやすくなったので、全サービスへ導入することで見落としや考慮漏れを防いでいきたいと考えています。

今後の展開

v2 API への移行、Istio への移行

設計当初の状況と、S3 を配信バックエンドに使いたいという要求から xDS API は v1 を使用してきましたが、v1 API は非推奨になっているのでこれを v2 へ移行する予定です。同時に control-plane を Istio へ移行することも検討しています。また、仮に control-plane を自作するとしたら、今のところの考えでは go-control-plane*20を使用して LSD/RDS/CDS/EDS API*21を作成することになると思います。

Reverse proxy の置き換え

今までクックパッドでは reverse proxy として NGINX を活用してきましたが、内部実装の知識の差や gRPC 対応、取得メトリクスの豊富さを考慮して reverse proxy や edge proxy を NGINX から Envoy に置き換えることを検討しています。

トラフィックコントロール

Client-side load balancing への置き換えと reverse proxy の置き換えを達成すると、Envoy を操作してトラフィックを自在に変更できるようになるので、canary deployment や traffic shifting、request shadowing を実現できるようになる予定です。

Fault injection

適切に管理された環境でディレイや失敗を意図的に注入して、実際のサービス群が適切に連携するかテストする仕組みです。Envoy に各種機能があります*22

分散トレーシングを data-plane 層で行う

クックパッドでは分散トレーシングシステムとして AWS X-Ray を利用しています*23。現在はライブラリとして分散トレーシングの機能を実装していますが、これを data-plane に移してネットワーク層で実現することを予定しています。

認証認可ゲートウェイ

これはユーザーリクエストを受ける最もフロントのサーバーでのみ認証認可処理を行い、以降のサーバーではその結果を引き回し利用するものです。以前はライブラリとして不完全に実装していましたが、これも data-plane に移すことで out of process モデルの利点を享受することができます。

終わりに

以上、クックパッドでのサービスメッシュの現状と今後について紹介しました。すでに多くの機能を手軽に実現できるようになっており、今後さらにサービスメッシュの層でできることが増えていくので、マイクロサービス各位に大変おすすめです。

*1:https://blog.twitter.com/engineering/en_us/a/2013/observability-at-twitter.html

*2:https://medium.com/@copyconstruct/monitoring-and-observability-8417d1952e1c

*3:https://github.com/taiki45/kumonos

*4:https://github.com/envoyproxy/data-plane-api/blob/5ea10b04a950260e1af0572aa244846b6599a38f/API_OVERVIEW.md

*5:gRPC アプリケーションはすでに本仕組みを利用して本番環境で稼働しています

*6:単純に Internal ELB (NLB or TCP mode CLB) を使った server-side load balancing ではバランシングの偏りからパフォーマンス面で不利であり、さらに取得できるメトリクスの面でも十分ではないと判断しました

*7:https://www.envoyproxy.io/docs/envoy/v1.6.0/api-v2/config/metrics/v2/stats.proto#config-metrics-v2-dogstatsdsink最初は自前拡張として実装したのですが後ほどパッチを送りました: https://github.com/envoyproxy/envoy/pull/2158

*8:https://github.com/prometheus/statsd_exporter

*9:https://www.envoyproxy.io/docs/envoy/v1.6.0/api-v2/config/metrics/v2/stats.proto#config-metrics-v2-statsconfigこちらも実装しました: https://github.com/envoyproxy/envoy/pull/2357

*10:https://prometheus.io/docs/prometheus/latest/configuration/configuration/

*11:https://github.com/rrreeeyyy/exporter_proxy

*12:https://medium.com/netflix-techblog/vizceral-open-source-acc0c32113fe

*13:https://github.com/envoyproxy/envoy/issues/1947

*14:https://github.com/nghialv/promviz

*15:https://github.com/mjhd-devlion/promviz-front

*16:NGINX で配信したりクックパッド内のサービス構成に合わせる都合上

*17:client-side load balancing を用いたアクセスを想定している関係で接続先の解決を行うコンポーネントが必要

*18:http://techlife.cookpad.com/entry/2018/04/02/140846

*19:リトライを設定している箇所もあったのだが

*20:https://github.com/envoyproxy/go-control-plane

*21:https://github.com/envoyproxy/data-plane-api/blob/5ea10b04a950260e1af0572aa244846b6599a38f/API_OVERVIEW.md#apis

*22:https://www.envoyproxy.io/docs/envoy/v1.6.0/configuration/http_filters/fault_filter.html

*23:http://techlife.cookpad.com/entry/2017/09/06/115710


総合職・デザイナー向け技術基礎研修 2018

$
0
0

こんにちは、技術部の長(@s_osa_)です。

先日、新卒の総合職・デザイナー向けに技術基礎研修を行ないました。 そこで研修をするにあたってどのようなことを考えて何をしたか、担当者の視点から書いてみようと思います。

なぜやるのか

研修を担当することになったとき、はじめに「なぜやるのか」「この研修の目的は何なのか」を考え直してみました。 ぼんやりとした「技術についても少しは知っておいてほしい」という気持ちはありましたが、研修内容を考えるにあたって目的を明確にする必要がありました。

研修を受けてもらうのは総合職・デザイナーの人たちです。 エンジニアに対して技術研修があるのは自然ですが、技術職ではない人たちに技術研修を受けてもらうのには然るべき理由があるはずです。

理由の言語化を試みたところ、「研修を受ける人たちは技術職ではないが、テクノロジーカンパニーの一員であることに変わりはない」というところに思い当たりました。 本人が技術職であるか否かにかかわらず、我々のサービスは様々な技術によって支えられていますし、日々の仕事も様々な技術なしには成り立ちません。

そこで「テクノロジーカンパニーの一員として、日頃使っている技術や今後触れるであろう技術を知り、活かしていけるようになる」を大きな目的に設定しました。

目的

上記の目的だとまだ少しぼんやりしているので、使っている技術や将来使う技術についてブレークダウンして以下の3つを目的として置きました。

  • クックパッドがどのように動いているか理解する
  • 適切なツールを用いてコミュニケーションや情報共有できるようになる
  • データを元に物事を考えるためのスキルを身につける

社内では「データ分析からUI改善」や「ディレクターがSQLを使えてよかった話」などのように、ディレクターやデザイナーがデータ分析をしてその結果を各種ツールを使って共有・議論しつつサービスを開発していくということが一般的になっており、その実態を反映した内容になっています。

指標

目的をもとに、研修後に受講者がどうなっていてほしいかという指標を考えます。

  • クックパッドがどのように動いているかイメージできる
  • 社内で使われているツールでスムーズにコミュニケーションができる
    • Groupad(Wiki+Blog のような社内ツール)や GHE(GitHub Enterprise)など
  • コミュニケーションや情報共有を行なう際に目的に応じて適切な方法を選択することができる
  • SQL を使用して簡単なデータを取得できる*1

これらの指標は定性的で厳密な評価などは難しいのですが、それでも指標を言語化しておく価値はあります。 構成を考えたり資料をつくったりする途中で「何を伝えるべきだろうか」「この内容は必要だろうか」といったことを考える機会が数えきれないほどあるのですが、そのときは指標と目的に立ち返って考えることになります。

内容

本研修に割り振られた日数は3日間・各日7時間*2で計21時間程度でした。 内容の分量なども考慮して検討した結果、3つある目的それぞれに1日ずつ割り振ることにしました。

各日の簡単な内容は以下のようなものです。 なお、昨年まではプログラミング研修が含まれていましたが、昨年以前のフィードバックを参考にしつつ限られた時間の中での優先順位を検討した結果、泣く泣く今年の内容からは削ることにしました。

1日目「クックパッドを支える仕組み」

クックパッドを例にとりつつ、日頃何気なく使っている「インターネット」はどのように動いているかについて、「クライアントとサーバがネットワーク越しに通信している」ということを軸に以下のようなトピックについて話しました。

  • コンピュータ
  • クライアント
  • サーバ
  • リクエストとレスポンス
  • ネットワーク
  • 社内のエンジニア
  • セキュリティ

この日に使った資料は本エントリの最後に公開・紹介します。

2日目「コミュニケーションと情報共有」

前述の Groupad や GHE で使われる Markdown の書き方について説明した上で実習した後、自己紹介を題材にして、実際に GHE の Web 画面を使って様々な操作を行なってみました(issue を立てる、PR を送る、レビューしてマージするなど)。

f:id:s_osa:20180502184359p:plain

また、社内ではコミュニケーションや情報共有のために他にも様々なツール(Slack、メール、Google Drive など)を使っているので、それぞれの特徴や使い分けるための考え方などについて話しました。

3日目「データ分析の第一歩」

日常的に行われているデータ分析の例を示した後、ハンズオン形式で実際のデータ(DWH)を触りながら SQL について学んでもらいました。 SQL の実行には社内でも広く使われている Bdashという BI ツールを使用し、適宜グラフなども描きながら進めていきました。

f:id:s_osa:20180502183205p:plain

分析が目的なので内容を select文に絞り、select, from, whereから始めて group by, having, joinなどひと通りの句について説明しつつ、実際にクエリを書いてもらって答え合わせをしながら進めました。

ただし、次の日から SQL をガリガリ書いてもらいたいというわけではなく、基本的なクエリを通して SQL の強力さを体感してもらい、将来必要になったときに「SQL を書く」という選択肢が視野に入るようにしてもらうにするのが主目的でした。

大切にしたこと

構成を考えたり資料をつくったりする際には、いくつかのことを常に頭に置きながら進めていました。

頭の中に地図をつくる

3日間という限られた時間の中で覚えられることには限界があります。

そこで、隅々まで覚えてもらうことはあきらめて、全体像を掴んでもらうとともに将来何かあったときに「これ研修でやったやつだ!」となってもらえるようにすることに集中しました。

また、新しく知ることをただ丸暗記するのではなく、知ったことの関係性を掴みながら頭の中に地図をつくってもらうために以下のようなことを意識していました。

全体から細部へ

繰り返しになりますが、研修を受ける人たちには全体像を掴んでほしいのであって、隅々まで知ってほしいわけではありません。

そこで、全体像をイメージできるようになってもらうことを優先するために、必要に応じて(できるだけ嘘にならない範囲で)細部を捨てて説明しました。 細部の説明が必要な場合にも、一度全体を説明して全体像を掴んでもらってから細部に立ち入るようにしました。

身近なところから裏側へ

普段使っている例やこれから使う機会など、できるだけ受講者が身近に感じられるユーザー目線から話を始めて、必要に応じて裏側の仕組みなどを説明しました。

既存の知識と結びつける

新しい知識を単体で覚えるのは難しいので、具体例として普段使っているアプリの例を出したり、それまでの研修でやった内容と関連付けたりと、受講者が持っている既存の知識と結びつけながら話しました。 受講者が自身の知識をもとに具体例を使って質問してくれたときは最高のチャンスなので、そのような質問には必ず具体例を活かしながら答えるようにしました。

実物を見せる

概念だけを説明されて理解するのは難しいので、ネットワーク機器やサーバールームを見てもらう、開発者ツールを使って実際の HTML や CSS を覗いてみてもらうなど、可能な限り実物を見てもらうことによって少しでもイメージしやすくなるようにしました。

手を動かして身につける

最終的に手を動かして使う類のスキルは知ることよりも使えるようになることが重要なので、実際に身につくように手を動かす時間を多く取るようにしました。

寄り道も大切にする

先述の通り、全体的には細部にはあまり触れないようにしましたが、何かを学ぶ上で知的好奇心は非常に重要です。 そこで、受講者が質問をしてくれたり興味を持ってくれた点については、適宜、細部や背景も含めて説明しました。

結果

研修後、受講者に対して各指標についてどの程度達成できたかというアンケートを5段階で取ったところ、全体を通した平均が4.44という数値になりました。 この数値自体には大した意味はないのですが、感想なども含めて概ね良い研修だったとのフィードバックをいただいています。

この研修にどれだけの価値があったのかは、配属後に実際に仕事をしていく中でわかっていくものだと思います。

今回の研修が少しでも役に立つことを願いつつ、実務に入ってからのフィードバックを含めて来年の研修に活かしていきたいと考えています。

資料

2日目および3日目の資料については社内固有の情報が多く含まれているため公開が難しいのですが、1日目の資料については一般的な情報なので公開しておきます。

研修では目の前の受講者に最適化するために、口頭での例示や補足、ホワイトボードを使って図示しながらの説明などを多用しましたが、スライドから概要や雰囲気だけでも感じていただければ嬉しいです。

*1:データ分析を行なうためには SQL の知識だけではなく、取得したデータをどう扱うかという知識が必要不可欠ですが、本研修のあとに統計研修が控えていたため本研修ではデータの取得に的を絞っています。

*2:就業時間は8時間ですが、朝夕に振り返りなどの時間が毎日1時間設定されていました。

AWS Elemental MediaLive を使用したライブ動画配信アプリの基盤開発

$
0
0

技術部開発基盤グループの @ganmacsです。 クッキング LIVE アプリ cookpadTVのライブ動画配信基盤(以下配信基盤)を AWS Elemental MediaLiveを使用して開発した話を紹介します。

cookpadTV 上のライブ動画配信基盤の役割と機能

cookpadTV では配信基盤を使ってライブ動画機能を実現しています。 cookpadTV とは料理家や料理上手な有名人による料理番組のライブ配信を視聴できるアプリです。 Cookpad Tech Kitchen #15や、すでにクックパッド開発者ブログに書かれた記事 1, 2を見るとどのようなアプリかをイメージがしやすいと思うのであわせてご覧ください。 配信基盤は cookpadTV 用というよりも様々なサービスで使える共通基盤になっています。 cookpadTV と配信基盤との関係は以下の図のようになっています。

f:id:ganmacs:20180509160937p:plain

配信基盤の大きな機能として

  • ライブ動画を配信できるようにする機能
  • ライブ動画を保存しておき後から見られるようにする機能

があります。ライブ動画を配信するときの配信基盤の動きは次のようになります。 配信基盤が番組の配信開始時間と終了時間を受け取って配信用 URL と購読用 URL を返します。 配信用 URL とはライブ配信したい動画を配信する URL で、購読用 URL とは配信された動画を視聴できる URL のことです。 配信プロトコルには RTMP を、購読には HLS をそれぞれ使用しています。 配信基盤は受け取った配信時間と終了時間をもとにライブ動画を配信できるように準備をします。 開始時間になると配信用 URL に対して動画のストリーミングが始まるので、配信基盤はその動画を様々な解像度にエンコードして配信しています。

ライブ動画を保存しておき後から見られるようにするために、ライブ動画を S3 に保存しておきます。 その動画を社内の動画変換サービスを使用して HLS に変換することでライブ動画をあとから見られる形にしています。

設計方針

動画を受け取りエンコードしているサーバから視聴者に配信するのではなく、配信についてはすべて S3 のようなマネージドサービスに任せられるように設計しました。 通常の動画配信の場合、コンテンツ全てを CDN で返してしまえばオリジンサーバへのアクセスをほぼ無くすことは可能です。 しかし、HLS でライブ動画を配信するにはプレイリストを頻繁に更新する必要があるため長期間キャッシュできないエンドポイントが存在し、オリジンにも高頻度にアクセスされる可能性があります。 そのような大量のアクセスに耐えられる必要があるため、ライブ動画やプレイリストを一度 S3 などにアップロードしてそこから配信できるように設計しました。 こうすることで、急に大量のアクセスが来た場合にも配信サービスのスケールをマネージドサービスに任せられるので運用が非常に楽になります。

採用理由

配信基盤で AWS Elemental MediaLive(以下 MediaLive) を使用してライブ動画配信を実現しています。 MediaLive を採用したのは、はじめに決めた設計方針を素直に実現できるからです。また、他にも以下の採用理由もあります。

  • マネージドサービスに任せられる部分は任せたい
  • S3 / AWS Elemental MediaStore(後述) とのインテグレーション
  • その他 AWS サービスとのインテグレーション

MediaLive は動画の配信先を AWS Elemental MediaPackage(以下 MediaPackage), AWS Elemental MediaStore (以下 MediaStore), S3 などから選べます。 MediaPackage は今回使用していないので紹介は割愛しますが、 MediaStore はメディア向けに最適化された S3 のようなもので、動画コンテンツをより効率よく配信可能です。 デフォルトで S3 や MediaStore を使用した配信をサポートしていることで、他のアプリケーションを使用した場合に比べてシンプルな実装で要求を実現可能なのが採用の最大の理由です。 また、MediaLive もマネージドサービスなので管理しているサーバのリソースを気にしなくていいこと、配信中のメトリクス3が Amazon CloudWatch(以下 CloudWatch) を使用して確認できることなども採用理由の一つです。

他の方法で実現不可能だったのか

MediaLive 以外にも、Wowza Streaming Engine(以下 Wowza) や nginx-rtmp-module などを使用することでライブ動画配信を実現するアプリケーションを作ることは可能です。 実は配信基盤の開発当初は MediaLive はまだ発表されていなかったため Wowza で動画配信をしようと考えており、実際にプロトタイプを作っていました。 しかし、Record HLS Packet and Upload to S3 each time.にあるように S3 へのアップロードは公式でサポートがされていないことや、その他アプリケーションを自分で管理しなくてはいけないことなどもあり、MediaLive を使用したアプリケーションに作り直しました。 MediaLive が発表された当初は、MediaLive についての記事などはほとんどなく知見が溜まっていないという欠点もありましたがドキュメントがしっかりと書かれていたので特に困りませんでした。

最終的なアーキテクチャ

配信基盤の大まかな役割として、ライブ動画を流すこと、そのアーカイブ動画を後から見れるようにとっておくことがあります。 それぞれについて最終的にどのように実現したかについて説明します。 以下がアーキテクチャの概要図になります。

f:id:ganmacs:20180509161423p:plain

配信基盤は配信の準備のために、開始時間より少し前に MediaLive を放送可能状態に変更したり、放送開始/終了検知をするために CloudWatch のアラームを設定したりします。 これらはバッチ処理によって行われています。 余談ですが配信が開始されたか/終了したかを取得する API が MediaLive には存在しません。 配信基盤では開始は S3 Event を、終了は CloudWatch を使用して、それぞれの情報を元に配信検知用のデーモンを起動しておき放送開始/終了を検知しています。 開始時間になると 配信用 URL に RTMP で動画が流れてくるので MediaLive が受け取り、設定した解像度に変換して MediaStore にアップロードします。 そして MediaStore をオリジンサーバとして CDN を経由して視聴者まで配信するようにしています。

アーカイブ動画は MediaLive の Archive Output Group を使用して、S3 にアップロードしておき、それを配信可能な形に変換して配信しています。 ライブ配信の終了時間になると、バッチ処理で S3 に保存されている動画をすべて集めてきて社内の動画変換サービスで変換して、アーカイブ動画として使用しています。 アーカイブ動画もライブ動画と同様に S3 をオリジンサーバとして CDN を経由して視聴者に配信されるようになっています。

まとめ

cookpadTV のライブ動画配信基盤の機能と、開発するうえで気をつけたことを紹介しました。 クックパッドでは AWS を利用して新たなサービスを作り出していける仲間を募集しています

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

$
0
0

技術部モバイル基盤グループの こやまカニ大好き( 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 でアプリ開発を行いたい仲間、開発を効率化する仕組みづくりに興味がある仲間を募集しています

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

$
0
0

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

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である@の役職でもあります

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

$
0
0

技術部の久須 (@) です。クックパッドではモバイル基盤グループにて 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
    • whで「最大」としているのは最終的に適用される値はアスペクト比を維持しながら決定される為
  • 変換後の画像品質(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変数)をそのまま利用し必要な箇所だけ上書きしている為、ETagLast-Modifiedヘッダはここで再設定しない限り S3 から返されたものがそのまま CloudFront に渡ります。変換オプション毎に変換後の画像データは異なる為、ETagLast-Modifiedヘッダも変換オプション毎に変更した方が良いと考えるかもしれません。ただ CloudFront でクエリ文字列込の URL ベースでキャッシュするようにしている場合は、ETagLast-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 を実際に運用してみた、などの事例がありましたら是非ブログ等で紹介いただければ幸いです。

参考にしたサイト

クックパッドは、RubyKaigi 2018 でみなさんにお会いできることを楽しみにしています!

$
0
0

こんにちは! 広報部のとくなり餃子大好き( id:tokunarigyozadaisuki)です。

クックパッドは RubyKaigi 2018にRuby Committers SponsorとNetwork Sponsor として協賛します。 Ruby Committers Sponsor とは、「Ruby Committers vs the World」に参加されるRubyコミッターの交通費をサポートするものです。 また、Network Sponsor に関しては、会場ネットワークの設計・構築・運用などを @sorahが担当しております。

そして、クックパッドに所属する5名(@pocke@riseshia@wyhaines@ko1@mame)が登壇し、4名(@nano041214@asonas@sorah@mozamimy)が運営として関わってくれています。

ブース出展やドリンクアップ開催もいたしますので、そちらも合わせて紹介します。 クックパッド社員は約40名参加しますので、みなさまと交流することを楽しみにしています。

登壇スケジュール

はじめに、社員が登壇するセッションのスケジュールを紹介します。

1日目 5月31日(木)

  • 16:40-17:20 桑原仁雄(@pocke):A parser based syntax highlighter
    @pockeが作成したIroというgemについてお話します。このgemは、Rubyのシンタックスハイライターです。今回はその特徴と実装についてを紹介します。
  • 17:30-18:30 ライトニングトーク
    • Sangyong Sim(@riseshia):Find out potential dead codes from diff
      Rubyで静的に未使用コードを探す時に間違って検出してしまうのを減らす方法について紹介します。

2日目 6月1日(金)

  • 10:50-11:30 Kirk Haines(@wyhaines):It's Rubies All The Way Down
    通常、Webアプリケーションスタックでは、アプリケーションそのものの処理にRubyを使用し、それ以外のレイヤーはRuby以外の言語で書かれているものをつかいます。発表では、その他のレイヤーについても、Rubyにしてみたらどうなるかを見ていきます。
  • 13:00-13:40 笹田耕一(@ko1):Guild Prototype今開発中の、Ruby 3 の並列並行処理のための新機能 Guild について、そのプロトタイプと実装方法を紹介します。
  • 16:40-17:20 遠藤侑介(@mame):Type Profiler: An analysis to guess type signatures
    Ruby3 の静的解析の構想をお話します。特に、Ruby プログラムから型情報を推定する型プロファイラの試作や検討状況に関する報告です。
  • 17:30-18:30
    • Cookpad Presents:Ruby Committers vs the World こちらの時間では、Cookpad Ltd CTOの Miles Woodroffe がご挨拶いたします。また、笹田耕一と遠藤侑介が司会を務めます。

3日目 6月2日(土)

  • 16:40-17:40 遠藤侑介(@mame):TRICK FINAL
    TRICK FINALとは、@mameが主催する変な Ruby プログラムで競い合うプログラミングコンテストです。 こちらの時間ではその結果を発表し、入賞作品を解説します。全く読めない、何の役にも立たない、実現不可能としか思えない珠玉の Ruby プログラムたちを楽しみましょう。

ブース

RubyKaigi 2018ではブースの出展もしております。下記スケジュールの通りライブコーディングや登壇者へのQ&A タイムなど、様々なプログラムを予定しております。グッズの配布も行いますので、ぜひお立ち寄りくださいね! 

1日目 5月31日(木)

  • 15:20-15:50 午後休憩: Cookpad live coding by @hokaccha
    @hokacchaがクックパッドのサイトを変更・デプロイする様子をブースでライブコーディングします。

2日目 6月1日(金)

  • 12:00-13:00 ランチ休憩:Q&A タイム by @pockeこの時間は、クックパッドブースに@pockeがおりますので、1日目 5月31日(木)16:40-17:20 A parser based syntax highlighter に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:20-15:50 Global Office Hoursクックパッドは、海外事業の全てを統括する第二本社をイギリス・ブリストルに開設しサービス展開を進め、展開国数は現在68カ国となりました。本時間には海外勤務経験のある社員がブースにおります。海外で働くことに興味がある方は、ぜひお気軽に話しを聞いてみてください! 

3日目 6月2日(土)

  • 12:00-13:00 ランチ休憩:Q&A タイム by@wyhainesこの時間は、クックパッドブースに@wyhainesがおりますので、2日目 6月1日(金)10:50-11:30 It's Rubies All The Way Down に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:20-15:50 午後休憩:Ruby interpreter development live @ko1@mameによるRuby インタプリタのライブコーディングです。登壇時の発表内容に関してご質問がある方も、この時間にお声がけください。 

Drink Up

Cookpad X RubyKaigi 2018: Day 2 Party

Cookpad international HQ team is hosting a party in the evening on Day 2 of RubyKaigi 2018. Come along to network, meet other Rubyists and perhaps a Ruby committer or special guest or two. How exciting!

www.eventbrite.com.au

Asakusa.rb × Cookpad「Meetup after RubyKaigi 2018」

Asakusa.rb × Cookpadのコラボレーションで、RubyKaigi 2018のアフターイベントを弊社オフィスにて開催します。美味しいお酒とご飯を食べながら、RubyKaigi 2018について振り返りましょう。 懇親会の時間もたっぷり取っていますので、お楽しみいただけたら幸いです。詳細は下記よりご確認下さい。

※ご好評を頂き、全て満席となりましたのでご了承下さい。たくさんのお申し込みありがとうございました。 cookpad.connpass.com

おわりに

会場でクックパッド社員をお見かけの際には、ぜひお声がけください。また、発表内容へのご質問やクックパッドにご興味をお持ちの方は、上記スケジュールをご確認の上、お気軽にブースまでお越しください。みなさまにお会いできることを楽しみにしております。

Railsアプリケーションでフォームをオブジェクトにして育てる

$
0
0

ユーザーエンゲージメント部の諸橋 id:moroです。

わたしはずっと、ユーザー登録やログイン周りという、サービス的には基盤的なところ、技術スタック的にはアプリケーション寄りのところに取り組んできました。関連する話を何度かこの開発者ブログにも書いています。

今日はそのあたりの開発を通じて考えた、Railsアプリケーションでのフォームオブジェクトやサービス層といったものが何であるか、という問いに対する、現在の自分のスタンスを紹介します。

サービス層、サービスオブジェクト、フォームオブジェクト

もともと Railsは Web 画面から DB 構造までをあえて密に結合させることで、簡単なサービスを高速に開発するフレームワークとしてデビューしました。と同時に、 Web アプリケーションフレームワークとしての使い勝手の良さや時流も手伝って、そう単純でないサービスを作るのにも使われるようになりました。

そうした背景も踏まえて、この数年は Rails の設計に関する興味も高まってきており、MVC だけでないレイヤの導入や DDD の諸アイディアの適用への興味も高まっているように思います。 中でもよく参照される考え方は、Code Climate 創業者の @brynary さんによる 7 Patterns to Refactor Fat ActiveRecord Models*1でしょう。

この記事では、モデルにまとめて書かれがちな処理を、Form Object や Service Object に分けていくことが提案されています。

あるいは、Trailbrazer や Hanami といった after Rails 世代のフレームワークにおいても、Operation や Interactor と呼ばれるレイヤやコンポーネントによって、Rails の素朴な MVC におさまりきらない処理を記述する場が用意されています。

筆者も、「ユーザー基盤の切り出し」として新たに Rails アプリケーションを作るにあたり、このあたりのアイディアをおおいに参考にしました。その上で、これらはつまり

『ActiveRecord::Base を継承し、永続化を中心に、バリデーションやコールバック、アクセサといった責務が詰まったモデル』や『HTTP リクエストを受けたり、処理結果のレスポンスを組み立てる責務をもったコントローラ』といったコンポーネントをそれぞれの責務に集中させたい、そのうえで必ずしもそのように分類できないアプリケーションのドメイン独自のロジックを書く場所がほしい、ということでないかと考えました。

このあたりで考えていることは、前回記事でも触れています。 そこで、分割した責務を配置する層を便宜上「フォームオブジェクト」と呼び、全体として収まりが良いコードになるように育てていくことにしました。 *2

またその際、新たなフレームワークを導入するのではなく、勝手知ったる Rails の上に app/formsというレイヤを作りできるだけプレーンな Rails (とは?) の構成ではじめました。

このレイヤのオブジェクトに求めるもの

フォームオブジェクトやサービス層、あるいは Interactor や Operation などいろいろな呼び方やそれに伴う視点の違いはありますが、共通しているのは以下の点です。

  • 外部入力(Railsで典型的なのはWebリクエスト、より具体的に言うと ActionController::Base#requestや 同#params )とドメインロジックを分離する。
    • ドメインロジックへの入力が単純なオブジェクトになり、入出力とロジック部分の境界がはっきりする。
    • ActiveJob として起動される非同期ジョブにしたくなった場合も、入出力境界がはっきりしているため、簡単に移行できる。
    • ユニットテストも書きやすくなる。
    • より高レベルなテストにおけるデータセットアップにて本物のロジックを呼び出せる。テストでも「本物の」データグラフを使うことができる。
  • Rails の素朴なアクティブレコードパターンだけでは収まらない処理を担う。
    • 複数のARモデルを一度に永続化するときに、データを組み立てる場がある。
    • 上記のシーンで頻出するものの扱いの難しい accept_nested_attributes_forを避けられる。
    • ActiveRecord モデルクラス (以下ARモデル) ではなくコンテキストに依存にする、バリデーション・コールバックを扱う。
    • 入力がリクエストパラメータやHTTPヘッダではなく、ただの文字列や数値になるため。

逆にいうと、処理を行うクラスを app/models/*に配置しつつ上記のようなアプリケーション内での境界に留意するのであれば、無理に新しいレイヤを導入する必然性は低いでしょう。

そのあたりは、チームの中で合意形成するのがよいと思います。

以降、実際のアプリケーションへのフィーチャ追加を通じて、このフォームオブジェクトの有り様を抽出しブラッシュアップしていった過程、並びにそこから考えたことを紹介します。

サービスの形をオブジェクトにする

以前に紹介したように最近、電話番号でもクックパッドへのユーザー登録できる、という大きなフィーチャをリリースしました。 これは、従来メールアドレスの登録を前提としていたクックパッドへのユーザー登録を、SMSで所有確認をした携帯電話番号でも登録できるようにする、というものです。

ひとくちに電話番号でのユーザー登録といっても、このフィーチャを実現するには文字通りの「登録」だけでは足りません。実際は以下のような機能がすべて必要になります。

  • 未登録のサービス利用者が、電話番号で新規にユーザー登録できる機能
    • 完全に新規の場合と、電話番号登録前にすでに有料サービスを利用開始している(システム的にはユーザーデータが存在する)場合がある。
  • メールアドレスとパスワードで登録したユーザーが、電話番号も追加登録できる機能
  • 電話番号を登録したユーザーが、その電話番号を変更できる機能
  • パスワードを忘れたユーザーが、電話番号の所有確認をしてパスワードリセットできる機能

できるだけ素直にフォームオブジェクトにしようとしていたため、「電話番号での新規ユーザー登録」の時点は無理に共通化しようとせず、完全新規の場合とすでに有料サービスを利用しているケースでそれぞれ一揃い個別のフォームオブジェクトを作りました。

最初の2例程度は良かったのですが、さすがに冗長に感じてきたため「電話番号の追加登録」の実装に入るタイミングでもう一度よく考えてみました。

すると、これらの機能群はすべて「ユーザーが入力した電話番号に対し認証コードを含むSMSを送信し、その認証コードが一致していたら電話番号を所有している本人であるとみなす」(以下『認証コード突合』)という振る舞いを含んでいることに気づき、その方向でコードを整理していくのがよさと考えました。

とはいえ、コードの字面や現時点での動きが似ているからという理由だけでコードを安易に共通化すべきではありません。たまたま現在の挙動が同じであるのか、それとも対象ドメインで同一でみなして良いものであるのかをよく考え、共通化する範囲や方法を考えるべきです。そこでコードの事情からはいったん離れ、ドメインエキスパートと一緒に共通化の方向性を探ることにしました。

結果として「『認証コードの突合』と"何か"をする」という継承 + テンプレートメソッドパターンの作りではなく、「何かする過程において共通の『認証コード突合』をし、続きを行う」というコンポジション的な構造となるように共通化するようにしました。ドメインエキスパートとともにユーザーから見える振る舞いを考えても、「『電話番号での登録』is a『電話番号を確認してなにかする』である」「『電話番号の追加』is a『電話番号を確認してなにかする』である」というのはピンとこず、コンポジションになっているほうが違和感がないとのことでした。

こうではなく f:id:moro:20180529182315p:plain

こう f:id:moro:20180529182320p:plain

見出した形に向けてリファクタリングする

技術的にもサービスの概念的にもこの『認証コードの突合』が抽出できそうということが分かってきたので、その方向にリファクタリングしていきます。まず「電話番号の追加登録」の構造は以下の3種類の画面に分割することができそうです。

  1. 最初にユーザーが電話番号を入力するフォーム (PhoneNumberAdditionForm) のある画面
  2. 認証コードSMSを送信し『認証コードの突合』をするフォーム (PhoneNumberAddition::VerificationForm) のある画面
  3. 突合に成功したあとに、電話番号データを永続化し、そのあとで正常に追加できた旨を表示する画面

このうち 2.の『認証コードの突合』を行うフォームに必要な振る舞いを詳細に見ていくと、次のようになります。

  • 規定回数・時間内に、正しい認証コードを入力したとき、次のステップに進む
  • 一定の有効期限を過ぎてから照合された場合、最初から入力を促す
  • 間違った認証コードが入力された場合、規定回数以下であれば再入力を促す
  • 規定回数を超えて間違った認証コードが入力された場合、最初から入力を促す

そのあたりを踏まえて、このような構造にしました。

  • コード照合、その結果取得、失敗回数などによる再入力可否の判定を ::PhoneNumberVerificationFormとして抽出した。
  • 「次のステップ」を導出するために必要な振る舞いを PhoneNumberAddition::VerificationFormに残した。
  • コントローラからは PhoneNumberAddition::VerificationFormを参照する。
    • 照合結果取得メソッドなどを ::PhoneNumberVerificationFormに委譲する。
    • 委譲の宣言を含め、最終的に結合させる部分のみ、小さな module にして mixin する。
  • コントローラは、 #認証に成功した?が真の場合、 #次の遷移先を表すリソースを取得してリダイレクトする。

このようにすることで、個別のフォームクラスの責務がはっきりし、コードも整理できました。

f:id:moro:20180529182411p:plain

その後引き続き「電話番号の変更」や「電話番号でのパスワードリセット」を実装していきましたが、目論見通り PhoneNumberVerificationFormの修正はほとんど不要でした。これまた振る舞いの観点から言い換えると、すでに作って共通化された電話番号の所由確認の仕組みを使い、電話番号変更機能に必要なぶんのみ実装することで、機能追加できたことになります。

横展開のイメージ

f:id:moro:20180529182430p:plain

「フォーム」とはなにか

このように、アプリケーションのドメインを見ながらコードをリファクタリングしていった結果、あるフォームオブジェクト PhoneNumberAddition::VerificationFormの中から別のフォームオブジェクト PhoneNumberVerificationFormを呼び出す構造となりました。アプリケーションへのおさまり具合はよかったものの、これは「フォームオブジェクト」という名前から想像する、画面の入力項目を表す「フォーム」の形からは大きく異なります。

そこで、もしかして「フォーム」のもともとのニュアンスは違ったりしないかな、と思い調べたり Twitter でも聞いてみたりしましたが、やはり入力フォームからきているようでした。*3そのため独自研究の単語連想ゲームにはなってしまうのですが、この「フォーム」をアプリケーションにおけるドメインの「形」を写し取ったものと解釈できないかと思っています。

題材としているフィーチャには、電話番号の登録や変更という「形」があり、これらは一連の機能のエントリポイントであるため目に付きやすいです。いっぽうその一部分と認識されがちな『認証コード突合』フローも、それ自体が多くの機能を持った大事な「形」として扱い、抽出して独立させました。

さらにフィーチャ全体では、電話番号の変更や、電話番号によるパスワードリセットといった機能も必要となりました。この場合でも共通する『認証コード突合』をそのまま使いつつ、エントリポイントとして各機能を実装しました。結果、コードの追加量的にも、必要な工数的にも納得できる程度でつくることができました。フィーチャに対してよいモデルを作れたのではないかと思います。

冒頭でも触れたように、このようなRailsコントローラ層とモデル層の間にもう一層設け、ドメインの複雑な処理をそこに配置するという設計手法は、一般的になってきました。それを「フォームオブジェクト」と呼ぶか、あるいは「サービス層」と呼ぶかに関しては、筆者は実はそこまでのこだわりはありません。

一方、大事だと思うのは、そこにドメインの形が現れてくるように作る、あるいは現れるように継続的に手を入れていくことです。今回の例ではこのように『認証コード突合』を抽出しましたが、今後また新たな要求を実現すべく眺めた場合、別の形(フォーム)が浮かび上がってくるかもしれません。それを見逃さずに、柔軟に育てていけるようでありたいと思っています。

まとめ

ドメインのありように注意を払いながら、フォームオブジェクトを育てていった話をしました。

  • ドメインに関する処理を Web の入出力と分けるためにフォームオブジェクトを導入した。
  • 継続的にリファクタリングしコードを育てているうちに、フォームオブジェクトの構造とドメインの構造が一致した。
  • その経験から、「フォーム」という語について考察してみた。入力フォームとしてだけでなく、ドメインの形(フォーム)であることを意識すると、エンジニアだけでないチームみんなで同じ視点からソフトウェアを見られた。
    • スッキリハマったときは、とてもたのしかった!

こういうふうにアプリケーションの形を彫り出してみると、コードもスッキリするし、テストもしやすいし、ドメインエキスパートはじめチームの色んな人と認識が一致して、とても楽しい体験だった、という一つの小さなストーリーを紹介しました。お題となったフィーチャ自体はとてもニッチかと思いますが、なんらかみなさんのお役に立つとうれしいです。

明日 5/31 から RubyKaigi 2018 ですね。クックパッドでも、社員が発表したり、各種パーティーなどを企画しています。ブースなどもありますので、ぜひお立ち寄り下さい。もちろん、筆者も参加予定です。

たのしい RubyKaigi と、その後も続くよいソフトウェア開発を!

*1:@hachi8833 さんによる 邦訳

*2:前回記事の時点だと「サービス層」と読んでいましたね。後述のように、そこの呼称じたいへのこだわりはあまりなくなってきています

*3:https://twitter.com/moro/status/965444586276466690


オフィス・AWS環境をセキュリティ監視するためのログ収集

$
0
0

インフラストラクチャー部セキュリティグループの水谷 (@m_mizutani) です。

現在、クックパッドのセキュリティグループではセキュリティ監視を高度化に対して取り組んでいます。サービスに関連する部分の監視は以前からやってきたのですが、ここしばらくはそれ以外のインフラやオフィスで発生するセキュリティ侵害を検知することを目的とした監視基盤の構築に力を入れています。

昔は一般的にオフィス、インフラのセキュリティ監視と言えば、イントラネット内に閉じた環境でのログ収集から分析まで完結していたケースも少なくなったと考えられます。しかし現在だとインフラとしてクラウドサービスを多用したり、業務で使うツールをSaaSによって提供するという場面も増えているかと思います。このような状況だとセキュリティ監視のために見るべき箇所がばらけてしまうといったことが起こります。クックパッドでも積極的にSaaSやAWSを利用しており、個別のサービス毎にログは蓄えられているものの、それぞれを活用しきれていない状況が続いていました。この状況を改善するため、下記の図のように各所から必要なログをかき集めて利活用できる状態にすることが、「監視の強化」になります。

f:id:mztnex:20180529104148p:plain

必要なログを一箇所に集めてセキュリティ監視に利用できる状態を作ることで、以下のようなメリットがあります。

  • アラートの通知・対応フローを一本化できる: 監視している機器・サービスにおいて危険度の高いイベントが発生した場合にはセキュリティの担当にアラートとして通知してほしいですが、通知の方法が機器・サービス毎に異なると設定変更の漏れや、通知の受け取りをミスしてまう可能性もあります。例えば通知方法で言えば、メール通知のみ、syslogへの出力のみ、APIに問い合わせないとわからない、というようにバラバラなことはよくあります。また、通知するメンバーをローテーションしたりするといった管理もとても煩雑になってしまいます。これを一本化することで、どのような機器・サービスで発生したアラートでもスムーズに対応できるようになります。
  • ログの検索や分析が統一されたコンソールから実施できる: インシデントと疑わしい事象が発生したらそれが本当に影響があったのかをログを見ながら調査・分析しなければなりませんが、ログが適切に保存されているとしてもアクセスするためのインターフェース(コンソール)が分断されていると、検索や分析に支障がでます。経験したことがある方はわかるかもしれませんが、複数のコンソールをいったりきたりしながら様々な可能性を検証するという作業はかなりのストレスになります。また、作業が煩雑になり見るべきログを見逃したりログの相関関係を読み間違えることもありえます。これに対して、統一されたコンソールが用意されていれば同じキーワードで全てのログを一括して検索可能になりますし、データが正規化されていればまとめて分析することもでき、分析にかかる負担を大きく減らすことをできます。
  • ログの保全管理が容易になる: セキュリティ関連のログは監査の目的であったり、後から発覚したインシデントを調査するため、一定期間保全しておく必要があります。通常、各サービスのログもしばらくの間保全しておく機能を備えていますが、多くの場合は保全する期間がばらばらだったり、保全のポリシーが異なります。そのため管理が煩雑になり、必要な時にあるべきログが無いといったミスが発生しやすくなります。これを防ぐため、統一管理されたストレージにログを保全することで、保全の期間やポリシーの制御が容易になります。

この記事ではセキュリティ監視をするにあたって、実際にログの収集をどのように実施しているかを紹介し、さらに集めたログをどのように利用しているのかについても簡単に紹介したいと思います。

ログの収集

セキュリティに関連するログとして、主にインフラ(AWSサービス周り)のログ、オフィスネットワークのログ、スタッフが使う端末のログ、そしてスタッフが使うクラウドサービスのログという4種類を収集しています。こちらの資料でも少し触れていますが、全てのログは原則としてAWSのS3に保存し、その後S3から読み込んで利用するよう流れになっています。全てのログをS3に経由させるという構成は、以下の3つのポイントをもとに判断しました。

  • 可用性が高く、スケールアウトするか: ログに限りませんが大量のデータを継続的にストレージ入れる際、一時的に流量がバーストするというのは稀によくあることであり、ストレージ側はそれに対応するような構成になっている必要があります。特にDBなどは負荷をかけすぎるとそのままサービスが死んでしまうこともありえますし、かといって普段から余分にリソースを割り当てるとお金がかかります。一方、AWSのS3は投入しただけだと何もできませんが、高い可用性とスケールアウトを実現しているストレージサービスです。「ひとまず保存する」という用途ではスケールアウトを気にする必要もほとんどなく、安定して利用できるというメリットがあります。
  • ログ送信元と疎結合な構成にできるか: ログの収集・処理ではログの送信元からログを処理し終えるまでの一連のフローを考える必要があります。例えば一気通貫でストリーム処理をするような構成だと途中の処理に失敗したらまたログの送信元からやり直しということになってしまいます。しかし、一度S3に保存しておくことで何かあっても送信元まで遡る必要がなくなります。また、送信元のサービスを開発・運用しているのが別の主体だったとしても「とりあえずS3に保存してもらう」というお願いができれば、セキュリティのチームはそこから先の面倒だけ見ればよくなり容易に責任分解ができます。
  • 遅延が許容範囲に収まるか: 到着したログを順次処理していくシステムを作る場合、処理の目的によってどの程度の遅延まで許容できるかが設計に関わってきます。セキュリティ監視の観点で考えると一定のリアルタイム性が求められますが、その時間単位は必ずしも短くありません。例えば日本で大手のSOCサービスで監視および分析専属のスタッフがいる場合でも、通知や初動対応のSLAは10分〜15分程度となっています。クックパッドでも監視と分析をしていますが、専属のアナリストがいるというわけではないため対応に数分の遅れが出るのは運用上許容できると考えることができます。したがって、S3に一度ファイルを保存してから処理を実行しようとすると数分の遅延が発生する可能性がありますが、上記の理由により問題にはならないと判断しました。

加えて、S3にどのようなログを収集しているのかと、その収集の方法について紹介します。

インフラ(AWSサービス周り)のログ

クックパッドではサービスのためのインフラとして主にAWSを利用しています。インスタンスからのログ収集も必要ですが、それ以外にもAWSが様々なセキュリティ関連のサービスを提供しており、これも同様に収集して活用しています。

インスタンスのsyslog

各EC2インスタンスで発生するログを集約し、これもGraylogへと転送しています。こちらもここからインシデントを発見すると言うよりは、後からの調査に利用しています。インスタンスの種類によって細かい収集方法は様々ですが、基本的にはfluentdに集約され、その後でS3に保存されます。

CloudTrail

AWS上でのアクティビティをログとして記録してくれるサービスです。AWS上でのユーザやサービスの動きを追うことができるため、セキュリティインシデントに関連した分析だけでなく、デバッグやトラブルシュートにも利用されています。

CloudTrailは標準でS3にログを保存する機能があるため、これをそのまま利用しています。

GuardDuty

AWS上で発生した不審な振る舞いを通知してくれるサービスです。不審な振る舞いも深刻度でレベル分けされており、深刻度が低いものはEC2インスタンスに対するポートスキャン行為など、深刻度が高いものは外部の攻撃者が利用してるサーバと通信が発生していることを通知してくれます。

GuardDutyが検知した内容はCloudWatch Eventsとして出力されるため、これをLambdaで受け取ってS3に保存する、という構成で運用しています。

VPC flow logs

VPC内のインスタンスから発生する通信フローのログを保存しています。Flow Logsはインスタンスのネットワークインターフェースから発生した通信のフロー情報を全て記録してくれるため、アラート分析やインシデントレスポンスにおいて攻撃の影響判断や通信発生の有無を確認するのに役立ちます。

Flow logは通常CloudWatch Logsに書き出されるため、S3に格納するためにログデータのエクスポートを利用しています。

オフィスネットワーク関連のログ

NGFWのログ

通常のファイアウォールの機能に加え、通信の中身の一部も検査して脅威を検知・遮断してくれるNext Generation Firewall (NGFW) でオフィスネットワークの出入り口を監視しています。これによって怪しいサイトやマルウェアを配布しているようなサーバへのアクセスが検知され、場合によって自動的にブロックされます。

NGFWからは通信フローのログ、および脅威の検知やブロックのログの両方を収集しています。現在利用しているNFGWはsyslogでログを飛ばせるので、fluentdを経由してS3 output pluginを利用し、S3に保存しています。

DNSキャッシュサーバのログ

弊社で利用しているNGFWは通信のTCP/IPフローのレベルの情報は残してくれますが、IPアドレスとポート番号および通信量などの情報しかわからないので、どのような通信がされていたのかは把握が難しい場合があります。そこで名前解決のためのDNSのログをとっておくことで、分析のための材料を増やしています。IPアドレスだけだと判断が難しくても、どのドメイン名のホストと通信したかがわかることでインシデントを追跡できることがしばしばあります。

オフィスではDNSのキャッシュサーバとして unboundを利用していますが、出力されるログの形式が利用しづらいことから、unboundのログは直接利用していません。代わりにキャッシュサーバ上で packetbeatを動かしてパケットキャプチャし、DNSのログを収集したものを fluentd + fluent-plugin-beatsを経由してS3に保存しています。

DHCPサーバのログ

現在クックパッドではスタッフが使うPCに端末管理のソフトを導入していますが、このソフトは端末がどのIPアドレスを利用していたかを過去にさかのぼって調べられません。そのため、DHCPのログも別途保存しておくことで、後から分析する場合でもそのIPアドレスを使っていたのがどの端末だったのかを追跡できます。

クックパッドでは kea DHCP server を運用しています。標準で出力されるログに必要な情報が載っているので、fluentdのtail input pluginでAWS S3にログを保存しています。現状では kea DHCP server ログを読み込むための成熟した専用 fluentd plugin は無いようだったのでひとまず1行1ログとして扱い、後からパースして必要な値を取り出すようにしています。

スタッフが使う端末のログ

アンチウィルスソフトのログ

クックパッドではアンチウィルスソフトとしてCylance PROTECTを採用しており、マルウェアと疑わしいファイルの検知情報をアラートとして利用しています。

この製品はマルウェアと疑わしいファイルや振る舞いを検知した後にログをSaaS側で収集してくれます。現状ではLambdaから定期的にSaaSのAPIをポーリングし、新しいイベントが発生していることを検出した場合はS3に新たにログファイルを生成するようにしています。

スタッフが使うクラウドサービスのログ

G Suite

書類、メール、カレンダーなどを一通り利用しているG Suiteでは各操作のログを取得しています。これによって何か問題が発生したときにスタッフが業務で利用している書類がどこかへ持ち出された形跡があったかどうかなどを調べることができます。

G SuiteではReports APIが用意されており、これを使ってAdmin activity, Google Drive activity, Login activity, Mobile activity, Authorization Token activityを取得することができます。これらAPIにLambdaで定期的にアクセスし、ログデータとしてS3に保存しています。

Azure AD

Azure Active Directory (AD) は Single Sign On のサービスとして利用しており様々な外部サービスを利用する際の認証のハブになっています。こちらも何か問題があった場合にどの端末からどのサービスへのアクセスがあったかを辿ることができます。

Azure ADも監査APIが用意されており、ログインなどに関連するログを抽出することができます。これをG Suiteと同様に定期的に実行し、新しく取得できたものについてログデータとしてS3に保存しています。

集めたログの利用

本記事のテーマはログの収集ですが、集めた後にどのように利用しているかについても簡単に紹介します。S3に集められたログは現在は主にアラート検出とログ検索・分析の2つに利用されています。

アラート検出

ここでいうアラートとは「影響があったかどうか確定ではないがセキュリティ侵害があったかもしれない」という状況を指します。現在、開発の都合で複数のログを組み合わせたいわゆる「相関分析」まではできていませんが、インシデントの懸念があるログが到着した場合にアラートとしてセキュリティグループのメンバー(担当持ち回り)で発報して対応を促します。具体的には以下のようなログを利用しています。

  • NGFWがアラートとして不審なサイトなどへのアクセスを捉えた時
  • Cylanceがマルウェアと疑わしいファイル・プロセスを発見した時
  • GuardDutyが一定以上の深刻度があるイベントを報告してきた時

これらのログが発見された場合、統一された形式にログを変換した上でアラートとして発報されます。担当のセキュリティグループのメンバーは誤検知か否かを分析し、影響があるかもと判断された場合は然るべき人に協力を仰いだり、PCそのものやログの追加調査を実施します。

f:id:mztnex:20180529104217p:plain

検出されたアラートはPagerDutyによって担当のメンバーに通知される

ログの検索・分析

冒頭で述べた通り、集めたログは横断的にログを検索できることが真価の1つになります。クックパッドではログ検索のために Graylog を導入して検索コンソールを構築しています。現状、VPCのフローログやサービスのログといった劇的に流量が多いログについては投入せずにAthenaで検索できるようにするなど工夫をしていますが、それ以外については概ね先述したログをカバーできるようになっています。

これらのログはセキュリティグループのメンバーだけでなく、スタッフは(一部を除いて)ほぼ全てのログにアクセスできるようになっています。そのため、セキュリティの用途だけではなくインフラなどが関係するトラブルシュートにおいても利用されることがあります。

日常的な運用でこのログをひたすら眺めるというようなことはしていませんが、アラートを検出した時に関連するログを調べるために利用するケースが多いです。そのアラートの前後に発生したログや関連するキーワードが含まれるログを読み解くことで、そのアラートは我々にとってセキュリティ侵害の影響をおよぼすものなのか?ということを判断しており、このような作業をログの分析と呼んでいます。複数のコンソールをとっかえひっかえすることなく、キーワードと時間帯を指定するだけで関連するログの分析ができることで、アラート検出時に必要な作業量・時間を大幅に短縮しています。

例えば、GuardDutyから「あるユーザが普段と異なるネットワークからAPIを操作している」というアラートが発報されたとします。その時、Graylog で該当ユーザ名とその前後の時間帯を検索すると例として G Suite でサービスにログインしたログ、そのユーザ名についてメールでやりとりされたログ、AWSで他のAPIを発行したログを一気通貫で見ることができます。これによって、そのユーザがその時間帯にどのような活動をどのネットワークからしていたのかがワンストップで確認できるようになり、それが正規のユーザの活動だったのか、それとも外部の悪意あるユーザがなりすましていたのかを容易に判断できるようになります。

f:id:mztnex:20180529104231p:plain

関連するキーワードと時刻を入力すると複数種類のログを時系列順に一括して見えるのでスムーズに全体像を把握することができる

実装の構成

ここまで解説したログの収集・分析の基盤は以下の図のような構成になります。

f:id:mztnex:20180529104346p:plain

ログ収集のパートで説明したとおり様々な手段を使っていますが、ログは必ずS3に集約するようになっています。S3のバケットはログの種類やサイズ、他のコンポーネントとの兼ね合いで分かれており、流量が大きいものは専用のバケットを用意して、そうでもないものは1つのバケット内でprefixだけ分けるようにしています。

S3にログが保存されると AWS Simple Notification Service (SNS) にイベント通知が飛び、そこからログの転送やアラート検知に関する処理が実行されます。それぞれの機能はAWS CloudFormation (CFn) によって構成されています。このCFnの中身については別記事にて解説していますので、興味がありましたらご参照いただければ幸いです。

まとめ

今回の記事ではオフィスやSaaS、それにインフラ(AWS)からなぜログを収集するのか、そしてどのようなログをどのように収集しているのかについて事例を紹介させて頂きました。クックパッドでは現在、ログ収集だけでなくアラートの検出やアラート発生時の対応に関する取り組みもセキュリティ監視の一環として進めているため、それらについてもいずれ紹介できればと考えています。

iOSアプリの大規模なCustom URL Schemeを支える技術

$
0
0

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

今回は、iOSアプリでCustom URL Schemeを簡単に処理するライブラリを公開しましたので紹介します。

Custom URL Schemeは、アプリの特定の画面に遷移させることができるリンク(ディープリンク)を提供する機能です。

f:id:gigi-net:20180530205333g:plain

アプリ開発をしていると、Custom URL Schemeを用いたディープリンクを実装したい需要は多いでしょう。 特にクックパッドのような、ブラウザ版を提供するWebサービスですと、アプリとWebページの行き来のため非常に多くのCustom URL Schemeを処理する必要が出てきます。

現に、クックパッドアプリでは、30以上のパターンが遷移先として実装されています。

渡ってきたURLのパーサーを愚直に書いていくのは、コードの記述量も増えますし、どのようなURL Schemeが有効なのか簡単に見通すことは難しいです。

Crossroad

そこで、複雑なCustom URL Schemeのルーティングを簡単に実現するライブラリをOSSとして公開しました。

例えば、あなたがiOS上で「ポケモンずかん」を実装する仕事を請け負ったとしましょう。

Crossroadを用いると、以下のような記述でCustom URL Schemeのルーティングが行えます。

letrouter= DefaultRouter(scheme:"pokedex")
router.register([
    ("pokedex://pokemons", { context inlettype:Type? = context.parameter(for:"type")
        presentPokedexListViewController(for:type)
        returntrue 
    }),
    ("pokedex://pokemons/:pokedexID", { context inguardletpokedexID:Int= try? context.arguments(for:"pokedexID") else {
            returnfalse
        }

        guardletpokemon= Pokedex.find(by:pokedexID) else {
            returnfalse
        }

        presentPokemonDetailViewController(of:pokemon)
        returntrue
    }),
])
router.openIfPossible(url)

このように、Ruby on Railsのroutes.rbのようなルーティングを記述することができます。

この仕組みをクックパッドアプリでは、1年以上前から運用していたのですが、今回、別のアプリでも使いやすい形で提供するためにOSS化しました。

同様のライブラリはいくつか公開されていますが、Crossroadはこれらに比べ、パラメータをType-Safeに、そして簡単に取り扱うことができます。

使い方

Crossroadの基本的な使い方を見ていきましょう。

URLのルーティング

iOSでは、Custom URL Schemeからアプリが起動されると、UIApplicationDelegateapplication(_:open:options:)が呼び出されます。

基本的な使い方は、AppDelegateで、ルーティングを定義した Routerを生成し、そこでopenIfPossibleを呼び出すだけです。

import Crossroad

classAppDelegate:UIApplicationDelegate {
    letrouter:DefaultRouter= {
        letrouter= DefaultRouter(scheme:"scheme")
        router.register([
            ("scheme://search", { _ inreturntrue }),
            // ...
        ])
        return router
    }()

    funcapplication(_ app:UIApplication, open url:URL, options:[UIApplicationOpenURLOptionsKey: Any]) ->Bool {
        return router.openIfPossible(url, options:options)
    }
}

:から始まるパスは任意の文字列にマッチし、あとでマッチした値を参照できます。 URLパターンは定義順に上からマッチするかどうかが判定され、ブロックから trueを返された時点で探索を終了します。 複数のURLパターンにマッチしうる場合も、最初にtrueを返した物のみが実行されます。

パラメータの取得

:から始まるパスにマッチした文字列は Argumentとして扱われ、ブロックから取得することができます。

("pokedex://pokemons/:pokedexID", { context in// URLからポケモンずかん番号を取得guardletpokedexID:Int= try? context.arguments(for:"pokedexID") else {
        returnfalse
    }

    // 該当するポケモンを取得するguardletpokemon= Pokedex.find(by:pokedexID) else {
        returnfalse
    }

    // ポケモン詳細画面を表示する
    presentPokemonDetailViewController(of:pokemon)
    returntrue
})

ArgumentはGenericsを利用しているので、任意の型として受け取ることができます。

例えば、pokedex://pokemons/25のURL Schemeからアプリを起動した場合、ずかん番号25番のポケモンが表示されます。

enumの値を取得する

Argumentを利用することで、それぞれのポケモンの詳細画面へ遷移するURL Schemeを実装することができました。

今度はポケモンを検索する画面を作ってみましょう。

URLのクエリとして渡された値は Parameterとして扱われ、Argumentと同様にContextから取得することができます。

ここで、ポケモンのタイプを示すenum Typeを定義してみましょう。 Crossroadでは、Extractableというプロトコルに準拠させることで、任意の型をContextから返却することができます。

enumType:String, Extractable {
    case normal // ノーマルタイプcase fire // ほのおタイプcase water // みずタイプcase grass // くさタイプ// ...
}

enumを表す型であるRawRepresentableは、すでにExtractableに準拠しているため、これだけで文字列をenumにマッピングすることができます。

("pokedex://pokemons", { context inlettype:Type? = context.parameters(for:"type")

    // ポケモン一覧画面を表示する
    presentPokemonListViewController(of:type)
    returntrue
})

これで、pokedex://pokemons?type=fireというURL Schemeからアプリを起動すると、ほのおポケモンのみを表示する画面へ遷移することができます。

一般的な検索画面を実装する場合は、キーワードや並び順などをパラメータで受け取る実装が考えられるでしょう。

pokedex://search?keyword=ピカチュウ&order=asc

複数の値を取得する

ポケモンずかんを実装するに当たって、今度は複合タイプのポケモンをURL Schemeから検索したいという需要が出てくるでしょう。

Crossroadは、パラメータに渡されたカンマ区切りの文字列を配列としてマッピングする機能も提供しています。

// pokedex://pokemons?types=water,grasslettypes:[Type]? = context.parameters(for:"types") // [.water, .grass]

これは、Swift 4.1から利用可能になった、Conditional Conformanceを用いて、[Extractable]Extractableに準拠させることで実現しています。

extensionArray:Extractablewhere Array.Element:Extractable {
    staticfuncextract(from string:String) ->[Element]? {
        letcomponents= string.split(separator:",")
        return components
            .map { String($0) }
            .compactMap(Element.extract(from:))
    }
}

独自の型を取得する

もちろん、独自の型を取得することもできます。Contextから取得したい型をExtractableに準拠させましょう。

structPokemon:Extractable {
    letname:Stringstaticfuncextract(from string:String) ->Pokemon? {
        return Pokemon(name:string)
    }
}
// pokedex://pokemons/:nameletpokemon:Pokemon= try? context.arguments(for:"name")

このように、Crossroadでは、柔軟にパスやクエリパラメータの取得を行うことができます。

Dynamic Member Lookupを使ったインターフェイス

最後に、Swift 4.2から実装される新たな言語機能であるDynamic Member Lookupを使ったインターフェイスの構想を紹介します。

Dynamic Member Lookupは、動的なプロパティ生成を提供するシンタックスシュガーです。 クラスや構造体に@dynamicMemberLookupを宣言することで、ランタイムで評価されるプロパティを生成することができます。

Dynamic Member Lookupを宣言すると、subscript(dynamicMember:)の実装が要求され、プロパティアクセスを行ったときに、プロパティ名が引数に渡され実行されます。

@dynamicMemberLookupstructContainer {
    letvalues:[String: Any]subscript<T>(dynamicMember member:String) ->T? {
        ifletvalue= values[member] {
            return value as? T
        }
    }
}

letcontainer= Container(values:["name": "Pikachu"])
letname:String= container.name // Pikachu

本稿執筆時点では、Swift 4.2の正式版はまだリリースされていませんが、Swift.orgからdevelopmentのToolchainをダウンロードすることで、Xcode 9.3でも利用することができました *1

この機能をCrossroad.Contextに適用してみると、以下のように Argumentを取得できるようになりました。

// match pokedex://pokemons/:pokedexIDletpokedexID:Int? = context.arguments.pokedexID

この実装はまだmasterへマージしていませんが、別ブランチで公開しているので、興味のある方は見てみてください。

まとめ

今回はCustom URL Schemeを簡単にルーティングするライブラリを紹介しました。 ぜひ利用を検討してみてください。もちろんPull Requestもお待ちしております。

技術部モバイル基盤グループでは、OSSを通して問題解決をしていきたいエンジニアを募集しています。

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

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

*1:普通にビルドすることはできますが、静的解析の時点ではシンタックスエラーが発生します

AWS Lambda@Edge で画像をリアルタイムに変換する

$
0
0

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

今回は普段の業務とは少し異なるのですが、画像リクエストに応じリアルタイムに画像を変換してレスポンスするという仕組みを AWS の Lambda@Edge を用いて実現してみたので、構築した環境の内容やコードをここで紹介したいと思います。

注意:実運用している段階ではないので参考にされる場合はご注意ください。ちなみにクックパッドには本番環境とは切り離された調査・検証用の 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
    • whで「最大」としているのは最終的に適用される値はアスペクト比を維持しながら決定される為
  • 変換後の画像品質(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変数)をそのまま利用し必要な箇所だけ上書きしている為、ETagLast-Modifiedヘッダはここで再設定しない限り S3 から返されたものがそのまま CloudFront に渡ります。変換オプション毎に変換後の画像データは異なる為、ETagLast-Modifiedヘッダも変換オプション毎に変更した方が良いと考えるかもしれません。ただ CloudFront でクエリ文字列込の URL ベースでキャッシュするようにしている場合は、ETagLast-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 を実際に運用してみた、などの事例がありましたら是非ブログ等で紹介いただければ幸いです。

参考にしたサイト

AWS Lambda@Edge で画像をリアルタイムにリサイズ&WebP形式へ変換する

$
0
0

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

普段の業務とは少し異なるのですが、画像リクエストに応じリアルタイムに画像を変換してレスポンスするという仕組みを AWS の Lambda@Edge を用いて実現してみたので、構築した環境の内容やコードを紹介したいと思います。画像変換の内容はコードの実装次第で大概のことは実現できそうですが、今回のコードの内容はスマホ向け WEB サイトやモバイルアプリ向けの画像配信を想定し、通信容量の削減および表示速度の向上を目的とした画像のリサイズ(主に縮小)と WebP 形式への変換です。

注意:実運用している段階ではないので参考にされる場合はご注意ください。ちなみにクックパッドには本番環境とは切り離された調査・検証用の 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 を設定すれば問題ありませんが(ブラウザ向けに HTTP/2 もサポートするようにしておきましょう)、クエリ文字列はフォワード&キャッシュのキーに含める(クエリ文字列が異なれば別ファイルとしてキャッシュする)ようにしてください。これは後述しますがクエリ文字列で画像の変換オプションを指定する為です。また 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
    • whで「最大」としているのは最終的に適用される値はアスペクト比を維持しながら決定される為
  • 変換後の画像品質(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変数)をそのまま利用し必要な箇所だけ上書きしている為、ETagLast-Modifiedヘッダはここで再設定しない限り S3 から返されたものがそのまま CloudFront に渡ります。変換オプション毎に変換後の画像データは異なる為、ETagLast-Modifiedヘッダも変換オプション毎に変更した方が良いと考えるかもしれません。ただ CloudFront でクエリ文字列込の URL ベースでキャッシュするようにしている場合は、ETagLast-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 です。

HTTP/2 を有効にする為に、画像は HTTPS プロトコルで配信しましょう。

f:id:hkusu:20180524155006p:plain

おわりに

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

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

参考にしたサイト

AlexaでE2Eテストを書けるようにした話

$
0
0

研究開発部の伊尾木です。

研究開発部では、Alexaのスキルを公開しています(Google Assistantも公開していますよ!)。

今回はAlexaスキルのテストを便利にするKuchimaneというツールを公開したので紹介したいと思います。

E2Eテストが難しい

音声UIの開発はまだまだ新しい分野で知見やツールがそろっているわけではありません。 特に E2E (End To End) テスト、RSpecでいうところの Feature spec に相当するようなテストを行うことがとても困難でした。

AlexaでのE2Eテスト

以下のような一連の会話があったとします。

あなた「クックパッドを開いて」
Alexa「クックパッドへようこそ」
あなた「大根のレシピを教えて」
Alexa「大根ですね。サラダ、ナムル、スープのどのレシピがいいですか」
あなた「スープ」
Alexa「大根のスープですね。レシピを送信しました」

Alexaでは、この一連の会話「クックパッドを開いてから、レシピを送信するまで」をローカルでテストする方法がありません。 (Alexaのデモ環境に都度リクエストを投げればテストはできますが、やっぱりローカルだけでやりたいですよね)

会話の一部だけ、一回のやりとりだけのテストなら可能です。

例えば、「クックパッドを開いて」->「クックパッドへようこそ」 の組み合わせのみテストするといったことは可能です。 が、全部通したテストを書くことはできません。

通常のWebアプリのテストでいえば、 「一回のHTTPリクエストごとのテストは可能だけど、複数HTTPリクエスト、あるいは複数画面にまたがるテストが書けない」 という状況と同じです。

とても不便ですよね。

なぜできないのか

Alexaでは、ユーザの生の発話を、開発者が直接操作することはありません。 一旦、内部的なインテントとよばれるユーザの意図を表している処理に変換します。

例えば「クックパッドを開いて」という発話は、内部的に LaunchRequest というインテントに変換されて処理を実行します。 Webアプリとの対比でいえば、URLからコントローラ・アクション名に変換するルーティング処理と同じような感じです。

このルーティング処理が、Alexa内部に隠蔽されているため、ローカルでテストすることができないのです。 どうしてもローカルで一連の会話をテストしたい場合、ルーティング処理を自前で処理する必要があります。

Kuchimane

私達も当初、インテント単位のテストだけで乗り切ろうとしていましたが、複数インテントが絡む処理はテストできないため、エラーが起きやすい状況でした。

そこで、一連の会話をテストするための Kuchimaneを開発しました!

じゃぁ Kuchimane では、さきほどのルーティング処理をどうしているのかというと、これまた自前で実装しています。

より正確にはsatori-flowというルーティング処理用のライブラリを開発しています。 KuchimaneがAlexaの会話モデル定義を解析し、このsatori-flowに「どんな発話がどのインテントになるのか」を登録します。

というわけで、以下のようなコードが書けるようになります!

const intents = { LaunchRequest, SearchDishIntent, SearchRecipeIntent };
const kuchimaneRunner = Kuchimane.runner(intents, __dirname + '/kuchimane_config.json');

it('searchRecipe', () => {return kuchimaneRunner.talkCheck('クックパッドを開いて', (message) => {
      expect(message).to.include('クックパッドですね')
    })()
    .then(kuchimaneRunner.talkCheck('大根のレシピを教えて', (message) => {
      expect(message).to.include('大根ですね。サラダ、ナムル、スープのどのレシピがいいですか');
    }))
    .then(kuchimaneRunner.talkCheck('スープ', (message) => {
      expect(message).to.include('大根のスープですね。レシピを送信しました');
    }))
  }
);

最初の行でintentsというオブジェクトを生成していますが、ここのLaunchRequestSearchDishIntentSearchRecipeIntentがインテント関数になります。 次にkuchimaneRunnerというインスタンスを、さきほどのintentsとKuchimane用の設定ファイル(Alexaのモデルへのパスなどを書く)から生成しています。

kuchimaneRunnertalkCheckというメソッドがE2Eテスト用のメソッドになります。第1引数がユーザの発話、第2引数がチェック用の関数になります。

talkCheckメソッドはユーザの発話を受け取ると、それを satori-flow に渡してインテント名に変換してもらいます。 そして、kuchimaneRunnerの生成時にもらったintentsの中から、インテント名にマッチする関数を取り出して実行し、Alexaのレスポンスをチェック用の関数に渡してテストを実行します。 最後にtalkCheckメソッドは、Promiseを返しますので、thenで会話を繋げていきます。

一連の会話をテストで書けることがわかりますね! 便利ですね!!

おわりに

AlexaのE2Eテストのための Kuchimaneの紹介でした。

バグの多くは機能の組み合わせ部分に潜むと言われますが、実際私達も複数の会話、複数のインテントが絡む部分でよくエラーが起きていました。 Kuchimane以前は、このような部分をテストすることができなかったのですが、Kuchimaneのおかげで複数の会話が絡む部分をテストできるようになり 品質向上に一定の効果があるなと感じています。

ちなみに、まだまだKuchimaneの完成度は高くありません。例えばASK SDK v2 にも対応できていませんし、私達にとって必要な部分を優先的に実装しているため、フォローできていないケースもあります。これらの点については今後拡充していく予定です。

また現状ではGoogle Assistantに対応していませんが、こちらも今後対応する予定です!

Viewing all 801 articles
Browse latest View live