ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築

こんにちは。PlayStation 5が一向に買えない@giginetです。普段はモバイル基盤部というところでiOSの基盤開発をしています。

皆さん、行動していますか?我々は日々Webサービス上で様々な行動をしています。サービス開発において、改善に活かすための効率的な行動ログの収集方法はしばしば課題になります。

今回は、サービス開発者がモバイルアプリ上で簡単にログを定義し、分析を行えるログ基盤を導入した事例について紹介します。

行動ログとは何か

モバイルアプリの行動ログとは、ユーザーのアプリ上の操作や利用状況を取得、集積するためのものです。 例えば、特定の画面を表示したり、特定のボタンをタップしたり、といったユーザー操作を起点として送信されています。

集められたログは、サービス開発のための分析や実態把握に役立てられます。

最近はFirebase Analyticsなど、PaaSの形態で提供されるログ基盤も増えてきました。 一方で、クックパッドのようなサービス規模になると、流量やコスト、ニーズへの適合という面から独自のログ基盤を構築しています。

以下の記事では、クックパッドを支えるログ基盤の概要について説明しています。主にバックエンドの構成などに興味がある方はご覧ください。

一般的な行動ロガーの実装

この記事ではクライアントサイドのログ実装に着目していきましょう。 なお、今回紹介するログ基盤は、どのクライアント実装によっても利用できる仕組みですが、この記事では、Swiftで記述したiOSアプリでの利用を例に取っています。

一般的な行動ロガーの実装として、以下のようなものが思い浮かぶでしょう。

Analytics.logEvent("select_content", parameters: [
  "content_type": "image",
  "content_id": "P12453",
  "items": [["name": "Kittens"]]
])

これはFirebase Analyticsのドキュメントで説明されているロギングの実装例です。

この方式ですと、クライアント側から任意のログを柔軟に送ることができます。しかし、スキーマレスであるこのようなログ実装の規模が大きくなっていったとき、すぐに収集がつかなくなってしまうのは想像に難くないでしょう。

これまでの問題点

クックパッドアプリの規模になると、日々膨大な量のログが送付され、リリースを重ねるごとにログの送信箇所が増えていきます。

また、多くの開発者が関わっているため、実装者のほか、後世でそのログを分析する人やディレクターも細かな仕様を把握する必要があります。実際に起こりうる問題について見て行きましょう。

行動ログへの注釈が難しい

現実のサービス開発における行動ログには様々なコンテキストが含まれています。

例えば、その行動ログがどのタイミングで送信されるのか、どのバージョンから実装されているのか、どのような定義域の値を送るのかなどでしょう。

クックパッドでは、これらのコンテキストに注釈を与えるため、dmemoという社内サービスを用いています。

dmemoを用いると、データウェアハウス(DWH)上の全てのテーブルやカラムにメタ情報を付与することができます。

f:id:gigi-net:20201102143355p:plain
データベースドキュメント管理ツールdmemo

しかし、現実には、柔軟すぎるログ実装には様々な問題があり、その度に各ログの持つコンテキストが膨大な物になっていきます。

バージョンごとの挙動差異の追跡が難しい

モバイルアプリのリリースごとの細かな挙動の変化を追うのが難しい問題もありました。

ログの実装は、バージョン毎に細かな挙動が修正されたり、特定のバージョンにおいては不具合によって期待した値が送られていないという問題も度々起こりえます。

このような場合もdmemoに注釈を付与して管理する必要がありました。

f:id:gigi-net:20201102143437p:plain
dmemoでのバージョン毎の挙動差異の注釈の様子

ログの実装ミスが防げない

冒頭のロガーの例のようなスキーマレスなインターフェイスは、実装時の柔軟性が上がりますが、実装時のミスを防ぐことが難しくなります。

フィールド名のtypoや、必要なペイロードの欠損、型の間違いなどのミスは常に起こりえます。

ログの実装をミスしたリリースが世に出てしまうと何が起きるのでしょうか。そうです、またdmemoへの記述量が増えていきます。

ログイベントの廃止が難しい

ログの廃止の問題もありました。分析の必要性がなくなったり、該当する機能そのものがなくなったりしたケースです。

この場合は、あるバージョンからログが送信されなくなります。同様に、dmemoに全て記述しておく必要があります。

ログの送付し忘れやデグレーションに気付きづらい

逆に、送信しなくてはならないログの送信がされていないケースもあります。

これは、実装時の単なる実装漏れから、リファクタリング時にうっかり処理が消えてしまうことも考えられます。

上記のログの廃止も併せ、それぞれのログが送られているべきなのか、そうではないのか、確認が必要でした。

これらの問題は、全て現実世界における行動ログの持つコンテキストが膨大なことに起因します。このようなドキュメンテーションの多くをdmemoに依存する必要がありました。

ドキュメントからロガーの実装を生成するログ基盤

そこで、今回実現したのが、Markdownで書かれたログ定義ドキュメントからのログ実装の自動生成です。

ログ定義をヒューマンリーダブルなドキュメントとして記述し、そこからクライアント実装のコードを自動生成します。

例を見てみましょう。

1. ログ仕様を決めてドキュメントを記述する

まずログの仕様を決め、そのドキュメントを専用の文法に従ってMarkdownで記述します。

# recipe_search

レシピ検索画面のイベントです

## search

レシピ検索を行った際に送付されます

- keyword: !string 256
    - 検索キーワード
- order: SearchOrder
    - 検索順
    - latest,popularity

## show_recipe

検索結果画面からレシピ詳細画面に遷移する際に送付されます

- recipe_id: !integer
    - 表示したレシピのID

このとき、ログ定義にはドキュメントを追記することもできます。

ログ定義の構成

このMarkdownがどのような構造になっているかを簡単に見て行きましょう。ログ定義はカテゴリ、イベント、カラムの3つの要素で構成されます。

カテゴリ (recipe_search)

複数のイベントの集合です。近しい機能(例えば一つの画面上に実装されているものなど)を同一のカテゴリとして扱います。

大見出しがカテゴリを表現します。カテゴリはいくつかのイベントを持ちます。

イベント (search, show_recipe)

特定の行動に対応する単位です。「検索を行った」(search)や「レシピを表示した」(show_recipe)などの単位が挙げられます。

それぞれの小見出しが、そのカテゴリに属するイベントを表現します。

カラム

各イベントに付与されるペイロードを表します。各カラムは型を持ち、この型がデータベース上の型と一致します。

また、全てのイベントが共通して持つペイロードは別途定義しています。 例えば、送信日時やユーザーID、送信者のOSやアプリケーションバージョンといった項目が挙げられます。

2. クライアント用のログ実装を生成する

次に、トランスパイラを実行します。

$ ./generate-log-classes

これによって、例えばiOSアプリにおいては、以下のようなSwiftのソースコードが生成されます。

ここで生成された RecipeSearchが、アプリケーション上でログイベントを表現する型となります。

例えば、レシピ検索のイベントはRecipeSearch.search として利用できます。

/// レシピ検索画面のイベントです
public enum RecipeSearch: LogCategory {
    public static var categoryName: String { "recipe_search" }
    public var eventName: String {
        switch self {
        case .search: return "search"
        case .showRecipe: return "show_recipe"
        }
    }
    public func makePayload() -> [String: Any] {
        switch self {
        case let .search(keyword, order):
            return [
                "keyword": keyword.validateLength(within: 256).dump(),
                "order": order.dump(),
            ].compactMapValues { $0 }
        case let .showRecipe(recipeId):
            return [
                "recipe_id": recipeId.dump(),
            ].compactMapValues { $0 }
        }
    }

    /// レシピ検索を行った際に送付されます
    case search(keyword: String, order: SearchOrder)
    /// 検索結果画面からレシピ詳細画面に遷移する際に送付されます
    case showRecipe(recipeId: Int64)
}

3. アプリケーション上でログを送信する

最後に生成されたログイベントを用いて、ログを送付します。

ログを送付したいタイミングで以下を呼び出します。

presentRecipeDetailViewController(recipeID: 42)
logger.postLog(RecipeSearch.showRecipe(recipeId: 42))

このようにアプリケーション開発者は、ログの仕様をMarkdownで記述するだけで、簡単にログの実装を完了できます。

このロガーは、冒頭に紹介したスキーマレスなロガーと違い、各ログイベントが型で保証されています。 これにより、フィールド名の間違い、欠損、型の間違いなどのヒューマンエラーを防いでいます。

ログ基盤の構成

このログ基盤は、以下のような構成で実現されています。

f:id:gigi-net:20201102143935p:plain
ログ基盤の構成

ログ定義

ログ定義はMarkdownで記述され、各アプリケーションのリポジトリ上に置かれています。

ログ定義と実装が紐付くことで、ログ定義の変遷やバージョン毎の挙動の違いも、通常のソースコードと同様にバージョン管理ツールから追跡可能になりました。

Markdownパーサー

ログ定義Markdownをパースし、中間表現に変換します。これを実現するMarkdownコンパイラ daifuku を実装しました。Rubyで記述しています。

daifukuは、Markdownを中間表現(RubyオブジェクトやJSONなど)に変換することのみを担当します。ここで重要なのは、daifukuはコード生成の責務を負わないことです。

コード生成は各プロジェクトがパースされた中間表現を読み取って行います。

これにより、変換先の実装言語によらず、汎用的にこのログ基盤を利用できるようになりました。

クックパッドでは、これと同様のログ生成の仕組みを、iOSアプリのほか、Androidアプリと、Webフロントエンドでも実現しています。 daifukuが生成した中間表現を、それぞれSwift, Kotlin, TypeScriptのコードにトランスパイルすることで実現しています。

コンパイル時にログ定義の簡易的なバリデーションも行っています。定義が命名規則に合致するかチェックしたり、利用できないカラム名を定義していないかなどです。

トランスパイラ(コード生成機)

daifukuが生成した中間表現を、テンプレートエンジンを用いて、各言語の実装にトランスパイルします。 このトランスパイラは、各プロジェクト毎に用意されています。多くはRubyで実装されていますが、任意の言語で実装できます。

ロガー

トランスパイラが生成したログイベントを解釈し、ログ送信ライブラリに渡す層です。

アプリケーション開発者は、ログを送りたい場面でログイベントオブジェクトを作り、ロガーに渡します。

logger.postLog(RecipeSearch.showRecipe(recipeId: 42))

ログ送信ライブラリ

最後が、収集されたログをログバックエンドに送信するための層です。

iOSアプリでは、以前からクックパッドで利用されている、ログ収集ライブラリのPureeを用いています。

Pureeはログのバッファリングや永続化を自動的に行い、非同期でログバックエンドへのバッチ送信を行うライブラリです。

PureeはOSSとして公開されており、他社のプロダクトでも多く利用されています。

さらに簡単で安全なログ開発基盤へ

この仕組みを運用することで、冒頭に挙げた問題の多くを解決することができるようになりました。

ここからは、利便性と堅牢性をさらに高める、発展的な機能について説明していきます。

廃止になったログイベントの表現

ログイベントの廃止を、特殊なアノテーションで指定することもできます。

## [obsolete] tap_promotion_banner
    - バナーをタップしたときに送付されます
    - バージョンXX.Xからバナーは表示されなくなりました

ログ定義に[obsolete] 指定を追加すると、このログイベントをコード生成から除外することができます。

単純なドキュメントからの削除ではなく、特殊なアノテーションを付加して記述できるようにしているのは、ログ調査の簡略化のための工夫です。 これにより、Markdown上にドキュメントを残しつつ、アプリケーションからは送ることができないログを表現できます。

ログ定義の静的解析による実装し忘れの検知

冒頭で挙げた、ログの送信し忘れ、実装漏れはある程度の静的解析でチェックすることができるようになりました。 前述のobsolete指定がされていないかつ、アプリケーション上のどこからも送信されていないログは、実装忘れである可能性が高くなります。

このチェックをCI上で実行し、利用されていないログイベントが見つかった場合は、実装か廃止を促します。

IDEのドキュメンテーションとの統合

トランスパイル時に、各言語のドキュメンテーションの仕組みに沿ったコメントを生成することで、ドキュメントをIDEに統合することもしています。

Markdownに記述したドキュメントは、コード生成時にログイベントのDocumentation Commentとして出力されます。 これにより、実装時に簡単にログの仕様を把握することができます。

f:id:gigi-net:20201102144026p:plain
Xcode上でログ定義をコード補完している様子

言語機能による型安全なログイベント

コード生成時に出力先の言語の型システムを利用することで、さらに堅牢なロガーの実装を生成できます。

もう一度、レシピ検索の行動ログの例を見てみましょう。

# recipe_search

## search

- keyword: !string 256
    - 検索キーワード
- exclude_keyword: !string? 256
    - 除外キーワード
- order: SearchOrder
    - 検索順
    - recent,popularity

このとき、各カラムの型としてデータベースに格納するプリミティブな型(!から始まります)以外に、特殊な型指定を利用することで、ログイベントが要求するペイロードの型を細かく制御することができます。

いくつか例を見ていきましょう。

オプショナル型

型指定に?を付けることで、仕様上nullが入りうるカラム、そうではないカラムを区別することができます。

exclude_keywordカラムは検索時の除外キーワードです。通常のキーワードが必須であるのに対し、除外キーワードは必須ではありません。

オプショナル型は、上記の例にある exclude_keyword のような、状況により付与しないカラムの表現に役立ちます。

オプショナル型でないカラムは、自動的にnullを非許容とするため、実装時に空の値が送られてしまうことを型システムで防ぐことができます。*1

logger.postLog(RecipeSearch.search(keyword: nil, excludeKeyword: nil)) // keywordにnilは渡すことができない

カスタム型

カスタム型を使って、任意の型をペイロードとして渡すこともできます。代表的な使用例は、特定の定義域しか取らない文字列型の表現です。

order カラムは検索順を記録する文字列型のカラムです。新着順(recent)か、人気順(popularity)のいずれかの値を取ります。

このカラムを単なる文字列型として扱ってしまうと、アプリケーション実装者のミスにより、定義外の値が混入してしまう可能性があります。

logger.postLog(RecipeSearch.search(order: "oldest")) // このorderは渡せない

そこで、このようなenumを定義し、ログイベントがこの値を要求することで、定義外の値を送ってしまうことを型システムで防いでいます。

public enum SearchOrder: String, ColumnType {
    case recent
    case popularity
}

この場合は実態はStringのenumなので、ログバックエンドには文字列型として送信されます。

これらの工夫により、アプリケーション実装者が利用するロガーは型が保証されています。これにより、意図しない値が入り込むことを実装レベルで防いでいます。

logger.postLog(RecipeSearch.search(keyword: "いちごどうふ", 
                                                             excludeKeyword: nil, 
                                                             order: .popularity))

まとめ

今回はクックパッドのログ基盤のフロントエンドでの取り組みを中心にお伝えしました。

ログ定義がドキュメントベースで管理されるようになったことで、冒頭で挙げた種々の問題を一度に解決することができました。

このログ基盤の導入以前には、ログの実装コンテキストを追ったり、実装ミスを防ぐのが難しい状況でした。 しかし、導入後は、ドキュメントとログ実装が紐付くことで、開発・分析時共に、ログの設計、実装、利用が全て容易に行えるようになったことがおわかり頂けたと思います。

ログ基盤は、組織によってニーズが異なり、なかなか汎用的な仕組みを作ることが難しい領域だと感じています。 この記事が最高のログ基盤を作るための一助となりましたら幸いです。

クックパッドでは最高の行動ログ基盤を使って開発したいエンジニアを募集しています。

*1:なお、どれだけ気をつけてもDBに到達する頃にはなぜかnullになっているケースを完全に防げないことはあまりにも有名である

日々の簡単なプロトタイピングに Flutter を活用する

こんにちは、 CTO 室の山田です。 私は新卒入社から現在までずっと Amazon Alexa や LINE Clova などのいわゆるスマートスピーカーやスマートディスプレイ向けのアプリケーション開発に携わっています。

特に Amazon Alexa に関しては、日本だけでなく、スペイン、メキシコ、アメリカ、ブラジルの計 5 カ国にてサービスを展開しています。

現在は上記のプラットフォームへアプリケーションを公開する形でサービスを提供していますが、私たちが掲げている目標は「Voice User Interface の特性を活かし毎日の料理をもっと楽しみにする」ことであり、必ずしも特定のデバイスやプラットフォームに特化をして開発をするわけではありません。

例えば iOS/Android などのモバイルデバイス上での方が今より良いサービスを提供できるかもしれませんし、私たちでハードウェアを開発した方が良いこともあるかもしれません。 今のスマートスピーカーはいわゆるウェイクワードで呼び出してから指示を出しますが、実は料理のシーンではもっと別の良い方法があるかもしれません。

こういった可能性を模索する上で、 Voice User Interface というまだまだ技術的に発展途上な領域に関しては、そもそも実用できるレベルで動くのか。という技術的な検証が非常に重要となります。

しかしながら、さまざまな領域の技術的検証をする場合、当たり前ですがそれぞれについて小さくない学習コストがかかります。

これを理由にその可能性を掘ることは諦めたくないですが、出来る限り検証したい部分以外は最低限のコストで済ませたいです。

Flutter を用いたプロトタイプの開発

このような背景から、 低い学習コストで iOS/Android 純正ライブラリの性能を検証する用途で Flutter を活用することができるのではないかと実際にプロトタイプを作成し検証を行いましたので事例として紹介させていただきます。 今回はキッチンで料理をしている状況で iOS/Android 純正の音声認識ライブラリの認識精度がどの程度の性能かを検証するプロトタイプを作成しました。

まずはこちらをご覧ください。


目を見て話せるレシピアプリのプロトタイプ


目を見て話せるレシピアプリのプロトタイプ 2

人の目線に気付くと話を聞く姿勢になってくれて、手順を読み上げてくれるレシピアプリです。ゲームによくある感じの近づくと注意を向けてくれて会話ができる CPU みたいな感じのをイメージしてみました。

今回のプロトタイプでは、手順の読み上げや、以下の動画にある通り材料の分量の確認と、動画には無いのですが作った料理を写真に撮ってもらう機能を用意してみました。

リモートワークなので自宅での撮影になるのですが、リアルの日常的なプロトタイピングの風景だと思っていただければ幸いです。実際にこの機能を使って料理も作ったのですが、特に大きな問題もなく作り切ることが出来ました。 今回は技術的検証なので、例えば換気扇や水を流している状況下での認識精度も簡単にテストをしてみたのですが、問題なく認識していました。


目を見て話せるレシピアプリのプロトタイプ 3

今回のテストから以下のことがわかりました。

  • 意外とスマートフォンのマイクでもキッチンで十分に使える音声認識精度だった
    • 離れた位置から声で操作することを念頭に置いて開発されたデバイスではないため、あまりマイクの性能には期待をしていなかったが、料理中全体を通しても認識エラーは数えるほどだった。
    • 特に換気扇や水が流れている状態でも認識率に大きい影響がなかったことが印象的だった
    • 今回は認識できる発話のバリエーションが少なかったため、もっとさまざまな発話に対応させた時の認識精度についてもテストをしたい
  • 「目を合わせると話ができる」のコンセプトは微妙だった
    • やはり音声インターフェースの強みは「手」と「目」がいらないことなんだと改めて感じた。いちいちスマートフォンを覗き込みにいかないといけないとなると便利さ半減だなと料理している時に感じた。結局はスマートフォンが置いてある場所に動線が影響を受けるように感じた
    • 意外とスマートフォンが目を認識する精度は高く、逆に高すぎるせいで変に検知されないよう検知しづらい場所に置いて使っていたため、ちょっと不自然な動きをする羽目になった
    • 目を検知しづらい場所に置くのではなく、もう少し別の置き方とか、そもそも目を検知するルールを変更すればもう少し良くなる可能性を感じた。今は目をあわせなくとも両目の存在が検知されたら反応するようにしているが、それだと目が届くところに置きづらいのできちんと目を合わせないと反応しないようにすればもう少し使いやすくなるかもしれないと感じた
    • もしくは目を合わせる。ではなく、 Hand Pose Detection に置き換えても良いかも知れない
    • ウェイクワード無しで目を見ると話しかけられるのはそれなりに簡単だし楽だと感じた一方、ファミリーの環境だと誰に話しかけてるのか明示的でなくなってしまうのでちょっと微妙なのかも知れないなと感じた

これらの機能は、 Flutter のパッケージ越しに SFSpeechRecognizer や AVSpeechUtterance など iOS 純正の Framework のみで実現しています。また、同じパッケージで Android の純正 API のみで実現することも可能です。 さらに、まだ stable channel では提供されていないため今回は取り組みませんでしたが Web app や Linux app の開発もサポートを予定しているようなのでこれらのプラットフォームについてもコストをかけずに検証が可能になるかもしれません。

このように、さまざまなプラットフォームの技術的な検証を小さい学習コストで実現出来ました。

終わりに

今回は、モバイル向け OS 純正の音声認識ライブラリの性能を、出来る限り小さい学習コストで検証するために Flutter を活用しました。

Flutter を使い開発したアプリケーションはキッチンで料理をするのに十分使えるレベルのクオリティであったため、実際にそのアプリケーションを使いキッチンで料理をするプロトタイピングを行いました。

これにより、音声認識ライブラリの認識性能について、私たちが想定する利用シーンに特有の事柄に対しても検証をすることが出来ました。例えば今回は換気扇や水が流れているシーンでも問題なく認識できうることがわかったのは大きな収穫でした。

また、今回コンセプトとして据えた「目を見て話せる」というのはキッチンで料理をするシーンでは微妙で、もう少し洗練させるか、もっと別の良いトリガーを考える必要があると感じました。 これもモバイル向け OS の画像認識ライブラリの性能や仕様によるところがあるため、例えばペーパープロトタイピングなどでは実感するのが難しく、実際に簡単なアプリケーションを作って料理をしたおかげで得られた収穫でした。

このように、技術的な検証を含む日常的なプロトタイピングにおいて、 Flutter を活用することでモバイル向け OS のアプリケーションを簡単に開発し、プロトタイピングすることが出来ました。 今後も、より多くのプラットフォームで動くようになることを期待しつつ、さまざまなプロトタイピングのシーンで活用していこうと思います。

負荷試験用 Web コンソールの開発

技術部 Site Reliability (SR) グループの id:itkq です。2020 秋タイトルで一番期待しているのはおちこぼれフルーツタルトです。本エントリでは、Web サービスの負荷試験に対する障壁を下げるために、汎用的な Web コンソール開発に至ったまでの話を書きます。

Web サービスの負荷試験の障壁を下げたい

クックパッドでは、マイクロサービスを支える基盤が成熟しており、新規サービス開発や、サービスリニューアルなどの機能開発の場面では、疎結合な新規のマイクロサービスとして実装されることが多いです。このようなサービスをリリースする際は、予想されるトラフィックに対して、実際にそれを捌ききれるかどうかテストする、いわゆる負荷試験をすることは一般的です。これまで、サービスリリース時に、負荷試験をきちんと行うこともあれば、負荷試験を行わないこともありました。負荷試験が行われない理由は、そのコストの大きさにあると私は考えました。本質的であるテストシナリオの用意だけでなく、負荷試験ツールの選定・負荷試験環境の構築・負荷試験ツールの動作環境の構築などの手間がかかります。

SR グループでは、負荷試験の障壁を下げ、開発チームが気軽に負荷試験を行えるようにすることで、自分たちが開発するサービスのボトルネックの認識やキャパシティプランニングを行えるようにすることを考えました。そのために、SR グループがメンテナンスする負荷試験用プラットフォームの提供を目指しました。

Serverless-artillery を利用したプロトタイピング

このプラットフォームで用いる負荷試験ツールは、サーバーレスであることが理想だと考えていました。負荷試験ツール側のリソースが不足してしまうことはしばしばあり、リソースの不足の心配を減らすためです。また、pay-as-you-go であることは、負荷試験のユースケースにマッチしており、コストを最適化できる見込みがあったからです。サーバーレスで動作する現代的な負荷試験ツールを自前で実装することも考えましたが、同じような思想で作られた Serverless-artillery を見つけ、検証を行いました。このツールは、NodeJS 製の負荷試験ツール Artillery と、サーバーレスアプリケーションをデプロイするためのフレームワーク serverless framework を組み合わせ、Artillery を AWS Lambda 上で実行するものです。Lambda function の実行時間やリソースの制限に合わせてテストシナリオを分割し、同時並列に Lambda function を実行し、目標負荷を実現する仕組みです。テストシナリオはドキュメントが充実している Artillery の記法 (YAML) で書くことができます。例えば、https://example.com/ に対して 10 秒間、1 秒間に 1 仮想ユーザが GET リクエストする設定は以下のように記述します。

config:
  target: "https://example.com"
  phases:
    - duration: 10
      arrivalRate: 1
scenarios:
  - flow:
      - get:
          url: "/"

Serverless-artillery を使うには、上記の YAML (script.yml) に加えて、次のような内容の serverless.yml を用意します。

service: serverless-artillery-minimum
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
functions:
  loadGenerator:
    handler: handler.handler
    timeout: 300

次の操作で、負荷を発生させる Lambda function を含む CloudFormation stack がデプロイされます。

$ npm i -g slsart
(snip)

$ slsart deploy

        Deploying function...

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
........
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service serverless-artillery-minimum.zip file to S3 (17.91 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
...............
Serverless: Stack update finished...
Service Information
service: serverless-artillery-minimum
stage: dev
region: ap-northeast-1
stack: serverless-artillery-minimum-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  loadGenerator: serverless-artillery-minimum-dev-loadGenerator
layers:
  None
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

        Deploy complete.

負荷試験の実行は次のようになります。

$ slsart invoke --path script.yml

        Invoking test Lambda

{
    "timestamp": "2020-10-20T09:53:48.491Z",
    "scenariosCreated": 10,
    "scenariosCompleted": 10,
    "requestsCompleted": 10,
    "latency": {
        "min": 425.7,
        "max": 449.8,
        "median": 440.5,
        "p95": 449.8,
        "p99": 449.8
    },
    "rps": {
        "count": 10,
        "mean": 1.07
    },
    "scenarioDuration": {
        "min": 429.5,
        "max": 571.1,
        "median": 444.2,
        "p95": 571.1,
        "p99": 571.1
    },
    "scenarioCounts": {
        "0": 10
    },
    "errors": {},
    "codes": {
        "200": 10
    },
    "matches": 0,
    "customStats": {},
    "counters": {},
    "scenariosAvoided": 0,
    "phases": [
        {
            "duration": 10,
            "arrivalRate": 1
        }
    ]
}

        Your function invocation has completed.

(snip)

Serverless-artillery もドキュメントが充実しており、動作や設定について丁寧に説明されているため、詳しくは README を参照してください 。自分でいくつかテストシナリオを用意して想定通り実行できることを確認したり、長期間の ramp-up テストが期待通り動作することを確認しました。

次に、この Serverless-artillery を利用して、社内の環境で負荷試験をするための最低限の準備をしました。具体的には以下の項目です。

アクセストークンのハンドリング

社内の API サービスは OAuth2 をベースとした自前の認証認可サービスで認証認可を行うことが一般的です。これらの API サービスに対して負荷試験を行う場合、アクセストークンをリクエストヘッダにセットしたり、アクセストークンの有効期限が切れていた場合にリフレッシュする処理が必要になります。Artillery には、リクエストの前後やテストシナリオの前後でフックする仕組みがあり、これを使ってアクセストークンのハンドリングを実装しました。

リクエスト結果のメトリクスを扱うプラグインの改善

Artillery には、Plugin という仕組みがあり、Artillery 内部で扱うイベントに反応する処理を追加することができます。なかでも有用なのは、リクエスト結果をメトリクスとして外部に保存する monitoring plugin です。Serverless-artillery は AWS Lambda を前提としているため、手間が少ない monitoring plugin として artillery-plugin-cloudwatch をまず試しました。しかし他の plugin である artillery-plugin-influxdb などに比べ欲しい機能が不足していたため、自分でいくつか機能を追加しました。 それについて upstream にコメントを求めましたが、長らく反応がなかったため、現在はフォーク版を使っています。

Jsonnet による設定の抽象化

Serverless-artillery には、先述の通り 2 つの設定ファイルが必要で、1 つは serverless フレームワークの設定である YAML ファイル、もう 1 つは Artillery のテストシナリオである YAML ファイルです。それぞれの YAML ファイルは、対象が異なる負荷試験であっても共通部分が多いため、Jsonnet で抽象化するようにしました。Jsonnet は、社内で設定を記述するのに広く使われているテンプレート言語です。使用例として、ECS へのデプロイを行うための Hako の定義ファイルがあります 。

以下に抽象化のイメージを示します。

recipes/serverless.jsonnet

local serverless = import '../lib/serverless.libsonnet';
local config = serverless.productionConfig('recipes');
std.manifestYamlDoc(config)

lib/serverless.libsonnet

local tags = {
  Project: 'serverlss-artillery',
};

{
  productionConfig(name):: {
    service: std.format('serverless-artillery-%s', name),
    provider: {
      name: 'aws',
      region: 'ap-northeast-1',
      runtime: 'nodejs12.x',
      stage: 'prod',
      role: 'arn:aws:iam::XXXXXXXXXXXX:role/LambdaServerlessArtillery',
      deploymentBucket: {
        name: 'dummy-bucket',
      },
      stackTags: tags,
      logRetentionInDays: 7,
    },
    functions: {
      loadGenerator: {
        handler: 'handler.handler',
        timeout: 300,
      },
    },
  },
}

recipes/script.jsonnet

local script = import '../lib/script.libsonnet';

local config = {
  config: script.productionBase('recipes') {
    phases: [
      {
        duration: 1800,  // 30 min
        arrivalRate: 1,
        rampTo: 500,
      },
    ],
    variables: {
      recipe_id: [
      1, 2, 3, 4, 5,
      ],
    },
  },
  scenarios: [
    {
      flow: [
        {
          get: {
            url: '/v1/recipes/{{ recipe_id }}', // ランダムに variables.recipe_id が選ばれる
            beforeRequest: 'ConfigureAccessToken', // アクセストークンの処理
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

lib/script.libsonnet

{
  productionBase(name): {
    target: 'https://cookpad-dummy-api.com',
    processor: './custom-functions.js', // ConfigureAccessToken が定義されたファイル
    defaults: {
      headers: {
        'user-agent': 'serverless-artillery',
      },
    },
    http: {
      timeout: 10,
    },
    plugins: {
      cloudwatch: self.cloudwatchPlugin(name),
    },
  },
  cloudwatchPlugin(name):: {
    region: 'ap-northeast-1',
    namespace: 'serverless-artillery',
    dimensions: {
      name: name,
      stage: 'prod',
    },
  },
}

レシピサービスリニューアルリリースにおける負荷試験

実は Serverless-artillery を検証していた段階で、レシピサービスのリニューアルリリース前に負荷試験を行いたい、と開発チームから声がかかっており、プロトタイピングしたものを実際に利用することにしました。

レシピサービスは社内で最も歴史のあるサービスで、内部のマイクロサービス化やリファクタリングは進んでいるものの、それ専用の仕組みがあったりと複雑な構成です。レシピサービスについて、専用の負荷試験環境を構築することは非常に難しく、また大きな労力がかかることは予想できたため、細心の注意を払いながら「本番環境」で負荷試験を行いました 1。テストシナリオは基本的に開発チーム側で準備してもらいつつ、レビューは SR グループでも行いました。負荷試験はテストシナリオを微修正しつつ何度か実行し、ミドルウェアのボトルネックなどいくつかの脆弱な箇所が洗い出されました。

今回のリニューアルでは、新たに 2 つのマイクロサービスが BFF の下に追加されました。その 2 つのサービスが関わるエンドポイントをリストアップし、各エンドポイントの予想アクセスパターンとアクセス量を考慮しながら、開発チームを中心にテストシナリオを作ってもらいました。実際に使われたテストシナリオの 1 つは次のようになっていました (URL など一部加工しています)。

local constants = import '../lib/constants.libsonnet';
local script = import '../lib/script.libsonnet';

local name = 'renewal-0221';
local config = {
  config: script.productionBase(name) {
    phases: [
      {
        duration: 3600,  // 1 hour
        arrivalRate: 1,
        rampTo: 655,
        name: 'Warm up the application',
      },
      {
        duration: 900,  // 15 min
        arrivalRate: 655,
        name: 'Sustained max load',
      },
    ],
    payload: [
      {
        path: './data/payload.csv',
        fields: [
          'kiroku_image',
        ],
      },
    ],
    variables: {
      recipe_id: constants.recipe_ids,
      clipped_at: [
        '2020-02-18T12:33:22+09:00',
      ],
      time_zone: [
        'Asia/Tokyo',
      ],
      keyword: constants.keywords,
      order: [
        'date',
      ],
      tsukurepo_count: [
        10,
      ],
    },
  },
  scenarios: [
    {
      name: 'deau/sagasu',
      weight: 200,
      flow: [
        {
          get: {
            url: '/dummy/app_home/deau_contents',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
        {
          post: {
            url: '/dummy/app_home/sagasu_search_result',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              keyword: '{{ keyword }}',
              order: '{{ order }}',
            },
          },
        },
      ],
    },
    {
      name: 'clip',
      weight: 450,
      flow: [
        {
          post: {
            url: '/dummy/clip',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              clipped_at: '{{ clipped_at }}',
              time_zone: '{{ time_zone }}',
            },
          },
        },
        {
          get: {
            url: '/dummy/{{ resourceOwnerId }}/bookmarks?recipe_ids={{ recipe_id }}', // resourceOwnerId は ConfigureAccessToken により挿入される
            beforeRequest: 'ConfigureAccessToken',
            capture: [
              {
                json: '$[0].id',
                as: 'bookmark_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/{{ bookmark_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
    {
      name: 'kiroku',
      weight: 5,
      flow: [
        {
          post: {
            url: '/dummy/kirokus',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              items: [
                {
                  item_type: 'PHOTO',
                  data: '{{ kiroku_image }}',
                },
              ],
            },
            capture: [
              {
                json: '$.id',
                as: 'kiroku_id',
              },
              {
                json: '$.items[0].id',
                as: 'kiroku_item_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/kirokus/{{ kiroku_id }}/items/{{ kiroku_item_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

まずシナリオのフェーズは、最大 RPS を 655 として、1 時間かけて ramp-up した後にそれを 15 分維持するように設定されています。これは、ピークタイムに向けてアクセスが伸びる現実を反映し、増加する負荷をシステムがオートスケールで対処できることを試験するためです。シナリオは機能ごとに 3 つを用意しました。それぞれには weight で重み付けをして、予想アクセスパターンを反映しています。recipe_id などのテストデータは別途事前に準備しておきました。

負荷試験中は、関連するメトリクスのダッシュボードを注視していました。想定外の事態が起き、負荷をすぐに中断することが何度か起きました。しかし、サーキットブレイカーの発動や、デグラデーションの考慮がなされていたことにより、実ユーザに大きな影響を与えることはありませんでした。負荷試験により発見された脆弱な点と、その対応の例を以下に挙げます。

  • サービス X の ECS サービスのスケーリングポリシーの最大タスク数に当たってしまい、リソースが不足しレイテンシが増加した。最大タスク数を引き上げた
  • サービス Y のバックエンド Elasticsearch が CPU リソース不足になりレイテンシが増加した。Elasticsearch の Data ノードのスケールアウト、N+1 クエリの解消、追加でレスポンスのキャッシュを実装が行われた
  • サービス Z のバックエンド MySQL が CPU リソース不足になりレイテンシが増加した。Z 内でのキャッシュの実装の見直しが行われ、さらに MySQL 接続ユーザやコネクション周りの設定不備も見つかった

最初のうちは、開発チームと SR グループで一緒に負荷の見守りを行っていましたが、終盤はほとんど開発チームだけで負荷試験の実行や中断ができるようになっていました。結果的に想定のテストシナリオをすべてクリアし、キャパシティに自信を持って 100% リリースすることができ、キャパシティ起因の問題は発生しませんでした。本番環境での負荷試験は、それ自体が大きなテーマであるため、このエントリではあえて詳細を書いていません。近いうちに別のエントリとして公開したいと考えています。

Web コンソールの開発

レシピサービスのリニューアルリリースにおける負荷試験を通して、Serverless-artillery を利用したプロトタイプが実用に耐えそうなことが分かりました。このプロトタイプでは以下の問題点があることが分かっていました。

  • 負荷試験の操作が CLI で、かつ強い IAM 権限を持つ人しか実行できない
  • 負荷試験が今行われているのか、いないのかがすぐに分からない

これをより利用しやすくするため、Web コンソールを開発しました。普通の Rails アプリケーションとして実装し、F5 と名付けました。コスト最適化のため、データベースは Aurora Serverless (MySQL) を利用しています。例として、10 分間で 50 RPS まで ramp-up し、50 RPS を 3 分間継続するというデバッグ用の負荷試験を実行したときの様子が以下になります。

f:id:itkq:20201021212900p:plain
負荷試験中の F5 のスクリーンショット

f:id:itkq:20201021211348p:plain
itkq/artillery-plugin-cloudwatch により収集したメトリクスの Grafana ダッシュボード

f:id:itkq:20201021211408p:plain
負荷試験対象側の Grafana ダッシュボード

また、開発にあたり工夫した点は次の通りです。

開発チームへの移譲

この取り組みの当初の課題意識を解決するため、開発チームが自分たちで負荷試験を操作できる仕組みを入れることにしました。G Suite の OAuth2 を利用した認証認可を実装し、特定のサービス (エンドポイント) のオーナー権をユーザに与え、オーナーのユーザはそのサービスに対して負荷試験の実行や中止を行えるようにしました。また、すべての操作はログとして残すようにして、後から追跡可能にしました。

テストシナリオを GitHub で管理して同期する

テストシナリオは負荷試験において本質的で、ピアレビューしたい場面があります。標準的なレビューフローをとれるようにするため、テストシナリオは Jsonnet として GitHub で管理し、それを同期するようにしました。このように設定ファイルを GitHub で管理するのは社内でよくある手法のため、受け入れられやすいと考えました。

まとめ

社内での Web サービスの負荷試験について、現状と改善の余地を述べ、Serverless-artillery を使った負荷試験の検証、より利用しやすくするための Web コンソールの開発に至るまでを説明しました。開発した Web コンソールは、実際に数回負荷試験に利用されています。テストシナリオのレビューで SR グループが最初関わることもありますが、その後は開発チームがほとんど自分たちで負荷試験のサイクルを回せているという所感です。これ以外にも、負荷試験に利用できる周辺の仕組み2が整ってきており、負荷試験、さらにはキャパシティプランニングが開発において当たり前となっていくような開発体制になることを目指していきたいです。


  1. 先日のイベントの発表資料も参考になります https://speakerdeck.com/rrreeeyyy/cookpad-tech-kitchen-service-embedded-sres

  2. 例えば Aurora のクローンシステム https://techlife.cookpad.com/entry/2020/08/20/090000