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

こんにちは。メディアプロダクト開発部の長田です。

この記事では、クッキングLIVEアプリcookpadTVのLIVE中のコメント配信について工夫したことを紹介したいと思います。

2018/3/28 (水)に開催されたCookpad Tech Kitchen #15資料も合わせてご覧いただけると、分かりやすい部分もあるかと思います。

cookpadTV

cookpadTVでは、料理家や料理上手な有名人による料理のLIVE配信を視聴することができます。iOS/Androidのアプリがリリースされおり、LIVE配信を通して、分かりづらい工程や代替の材料の質問などをコメント機能を使って質問することができます。また、他のLIVE配信アプリのようにハートを送ることでLIVEを盛り上げることができます。

以下では、LIVE中のコメント配信を実装するにあたって私が課題だと感じたものと、それらをどう解決したのかを紹介します。

コメント配信の課題

コメント配信には次のような課題があると感じています。

1つ目は、パフォーマンスの問題です。LIVEの日時に合わせてユーザーが同時に集まるので、人気な配信ほど多くのユーザーがサーバーにリクエストしてきます。また、コメントだけではなくハートを送信する機能を設けており、これは気軽に連打できるようにしてあるので、リクエスト数も多くなることが予想されました。

2つ目は、双方向通信です。cookpadTVでは「料理家や有名人にその場で質問できる」のを価値にしていて、ユーザーのコメントは演者が読み上げて回答してくれたりします。 演者とユーザーのコミュニケーションと、それを見ている他のユーザーの体験を損なわないようにするために、サーバーとアプリの情報をある程度同期させておく必要がありました。

パフォーマンスを出すために

コメントを受けるAPIは別アプリケーションとして構築しました。コメントを受けるAPIはその他のAPIとは特性が違うので、コメントを受けるAPIだけをチューニングしやすくなるからです。 以下では、このコメントを受けるAPIサーバーのことを メッセージサーバー と呼び、その他のAPIサーバーを 通常のAPIサーバー と呼ぶことにします。*1

f:id:osadake212:20180412145148p:plain

まず、実装言語はgolangを採用しました。採用理由は以下が挙げられます。

  • 並列処理が得意な言語なので、同時接続を受け付けやすい
  • 後述のFirebaseを使うためのAdmin SDKが提供されていた
  • golang書きたかった

クックパッドはRubyの会社というイメージがあると思いますが、特性に応じてRuby以外の言語を選択できるよう、hakoを使ったDockerコンテナのデプロイ環境が全社的に整備されており、他のサービスでもRuby以外の言語で実装されているものがあります。*2 *3

また、hakoによってDockerコンテナがECSにデプロイされるようになっており、必要に応じてECSのAuto Scalingの設定ができるので、このメッセージサーバーも設定しています。これにより、アクセスが増えてきてサーバーリソースが消費され始めたらスケールアウトして、アクセスが減ってサーバーリソースに余裕がでてきたらスケールインするようになります。 また、Auto Scalingが間に合わないことが予想される場合は、予めコンテナ数を増やしておくようにしています。*4

さらに、WebアプリケーションはDBアクセスがボトルネックになりがちだと思うのですが、メッセージサーバーではDBにアクセスをしない、という選択をしました。一方で、DBにアクセスしないので認証と永続化について工夫する必要がありました。

認証については、メッセージサーバー用の寿命の短い認証情報(トークン)を通常のAPIサーバーで発行しておき、それをキャッシュに乗せておきます。各アプリはそのトークンを乗せてリクエストするので、メッセージサーバーはキャッシュを見に行くことで認証を実現しています。

また、永続化については非同期で行うようにしました。 コメント/ハートは後述のFirebase Realtime Databaseを使って各アプリに配信されており、LIVE配信中に永続化できなくてもよかったので非同期で行う選択をしました。

永続化の流れは、fluentdを使ってコメント/ハートのデータをS3に送ったあと、弊社のデータ基盤を使うことで、Redshiftに継続的に取り込まれるようになっています。*5 さらに、Redshiftに入ったデータは、Kuroko2を使ったバッチ処理によりMySQLに取り込む流れになります。*6

f:id:osadake212:20180412145141p:plain

これらの工夫をして、直近の配信ではピーク時 5,100rpm のメッセージを無事捌くことができました。

双方向通信

コメントやハートのやり取りで使用する、iOS/Androidアプリとの双方向通信を行うためにいくつかの手段を検討しました。

などを検討した結果、最終的にFirebase Realtime Databaseを使うことにしました。選択した理由としては、

  • iOS/AndroidのSDKが提供されており、アプリの実装工数が減らせる
  • 社内の他プロジェクトで導入されており、知見があった

というのが挙げられます。

また、Firebase Realtime Databaseに直接アプリが書き込むのではなく、以下の図のように、一度メッセージサーバーがリクエストを受け付けて、その内容をFirebase Realtime Databaseに書き込むようにしました。こうすることで、認証と永続化を実現しています。 つまり、Firebase Realtime Databaseをストレージとしてではなく、イベント通知をするために利用しています。これに関しては、この後のデータ構造の工夫と合わせて詳しく説明します。

f:id:osadake212:20180412145041p:plain

Firebase Realtime Databaseを使うことにしたので、データ構造を工夫する必要がありました。

cookpadTVでは、データ転送量を抑えるために最新のコメントだけを保存するようにしました。 具体的には以下のようなJSON構造にしています。(これはイメージなので実際のものとは異なります。)

{
  "latest_comment": {
    "user_id" : 1,
    "text": "こんにちはー"
  }
}

このような構造にしておいて、 latest_comment を上書き更新することで、各アプリに配布するデータは最新のコメント分だけになるので、転送量を抑えることができます。過去のコメントはアプリ側で保持しておいて、LIVE中に受け取ったデータは遡れるようになっています。

ただしこのデータ構造には、途中からLIVE配信を見始めたユーザーは過去のコメントを見ることが出来ないという課題が残っています。 この課題に関しては、直近のコメントはいくつか保持しておく、というものと、非同期での永続化のラグを短くした上でAPIでコメントを返せるようにする、という2つのアプローチのあわせ技で解決したいと思っています。

まとめ

この記事では、cookpadTVのLIVE中のコメント配信について工夫したことを紹介しました。 最後になりましたが、この記事がLIVE配信サービスの開発について、少しでもお役に立てれば幸いです。

*1:コメントだけではなく、ハート等、他のメッセージも受けるのでメッセージサーバーと呼んでいます。

*2:hakoの近況は本ブログでも紹介されています。http://techlife.cookpad.com/entry/2018/04/02/140846

*3:2018/02/10に開催されたCookpad TechConf 2018では、「Rubyの会社でRustを書くということ」というタイトルで弊社のkobaによる発表が行われました。 https://techconf.cookpad.com/2018/hidekazu_kobayashi.html

*4:LIVEコンテンツの集客予想に応じて、自動でコンテナ数を増やす仕組みを実装しています。

*5:本ブログの過去のエントリで、クックパッドのデータ基盤について紹介しているものがあるので、詳細はこちらを御覧ください。 http://techlife.cookpad.com/entry/2017/10/06/135527

*6:弊社のオープンソースで、WebUIが用意されているジョブスケジューラーです。

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