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

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

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

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

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

インフラストラクチャー部セキュリティグループの水谷(@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

class LogCounter(slips.interface.Handler):
    def setup(self, args):
        self._namespace = args['namespace'],
        self._count = 0

    def recv(self, meta, event):
        self._count += 1

    def result(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 を紹介しました。今後、新たにサーバーレスアプリケーションを構築する方の参考になれば幸いです。

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

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

 今回は私が開発に関わっている 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 を使用して実現しています。

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

def schedule_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 を計算
    },
  })
end

def aas
  @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 環境が出来ました。 この辺りの基盤がしっかりしている事は今回非常に助かりました。

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