Yatagarasuに対するリクエストの具体例を説明

江頭宏亮氏(以下、江頭):次に、オリジンのYatagarasuへのリクエストを具体的にどのようにやっているのか、説明したいと思います。

先ほど見せたスクリーンショットだと、右側にモジュールが3つ並んでいたと思いますが、実際は縦スクロールでもっと大量のモジュールが表示されているので、一度のリクエストで複数のモジュールをハンドリングする必要があります。なので、FetchModulesの関数の引数にidsが複数来るようになっていて、ここにもモジュールのIDみたいなものが入ってくるかたちになっています。

今回、万が一、Yatagarasuの仕組みが高負荷状態になってスローダウンすることがあってもきちんと動くように、まずタイムアウトの設定を入れています。これを書かないと、万が一Yatagarasuの本体のサービスがスローダウンしたケースにgoroutineが滞留し、OOMになることが想定できたので、ここでタイムアウトをきちんと設定しています。

そして、複数のモジュールで並列に取りに行くためにgoroutineを作成して、一つずつ取りに行って、最終的にWaitGroupで待つということをやっています。取れたあとにインメモリとRedis、それぞれにキャッシュするという処理になっています。

キャッシュスタンピード問題を防ぐためにsingleflightを活用

また、万が一キャッシュがなくなった時に、オリジンに行くリクエストが集中する、キャッシュスタンピード問題を防ぐために、singleflightというパッケージを活用しています。

このsingleflightというパッケージは、同時に関数を呼び出すことを抑制できます。なので、キャッシュが切れたタイミングでオリジンにむちゃくちゃリクエストが行くというケースが発生したとしても、それを1つにまとめてくれるので、1つのリクエストしか飛ばないということが実現できます。

(スライドを示して)ちょっと見にくいかもしれませんが、singleflight.Doというのがその具体的な関数になっていて、そのDoの関数の第1引数には、リクエストを識別するキー、文字列を指定します。第2引数に具体的な処理、関数を渡します。

そしてDoの返り値は3つあります。module、errorというところに具体的な処理の返り値がそのまま来て、具体的に処理がまとめられたケースの場合、sharedにtrueとかが返ってきます。

なので、このサンプルのケースだと、処理が返ってきた返り値でsharedがtrueだった場合、functionは共有された、リクエストはまとめられたよということ。そのあとに通常どおりエラーのチェックをして、このDoの返り値はinterface{}、anyになるので、最終的にキャストする。こういう処理を入れています。

10分の1のインフラリソースで捌けるようになった

江頭:最後にまとめになるのですが、レコメンドシステムへのリクエストを削減するために今回Goでプロキシを開発しました。もともとインメモリのキャッシュだけを検討していましたが、よりオリジンへのリクエストを減らすために、インメモリのLRUとRedisの2層構造を採用しました。

また、キャッシュが切れた時にオリジンへのリクエストが集中して複数回飛ぶのを防ぐために、singleflightパッケージを活用してオリジンへの同一リクエストを抑制しています。

結果、時間帯にもよりますが、オリジンへのリクエストは90パーセントぐらい削減でき、これによって10分の1ぐらいのインフラリソースで捌けるようになったので、インフラコストの最適化を実現できました。

以上です。ご清聴ありがとうございました。

(会場拍手)

キャッシュのヒット率を上げるために何をしているか?

司会者:江頭さん、発表をありがとうございました。Slidoでけっこういっぱい質問が来ているので、順々に聞いていきたいと思います。

1個目が「特徴量が多いと、キャッシュキーの空間が広くなってヒット率が下がってしまうのではないか?」という質問です。そのヒット率を上げるためになにか工夫はしていますか?

江頭:そうですね……。ものによっては、ほとんどキャッシュがヒットしないものもありますが、レコメンドチームとけっこう連携して特徴量を最小限にするとか、そういう取り組みは最初にしました。

司会者:じゃあ、けっこう別のチームと密接に事前に打ち合わせをして設計して作った感じなんですかね。

江頭:そうですね。

司会者:なるほど。ありがとうございます。

スクラッチだからこそ出せたバリューとは?

司会者:ちょっといっぱい(質問が)来ているので、どんどんいこうと思います。「先ほど前段でご紹介されていたように、Goのプロキシサーバーをスクラッチで開発されたと思いますが、スクラッチだからこそ出せたバリューって何ですか?」という質問があります。

江頭:たぶん標準のリバースプロキシみたいなものは、パッケージに実装されていると思いますが、それがちょっと使えなかったという背景があるので、型変換みたいなことをやって、独自の処理をいろいろ実装できる点がメリットですかね。

司会者:やはりABEMAゆえにけっこう独特な処理も多いのかなと思うのですが、そういったところで使っている、バリューが出せたという感じですかね。ありがとうございます。

goroutineで処理する上での判断基準は何か?

司会者:けっこう似たような質問もいろいろ来ているのですが、やはりGoといえば、先ほどもおっしゃっていただいたとおり、並行処理があると思います。それを使う箇所、使わない箇所。けっこう「サーバーサイドのアプリでgoroutineを使うべきなのか?」という論争もよくあるのですが、「ここはgoroutineで処理しよう」みたいな判断は何を基準にされたんですか?

江頭:基本的には、I/O waitが発生するような処理は、goroutineにしています。

司会者:なるほど。ということは、I/O waitを待っている間にgoroutineで非同期でなにか別の処理をしておいて、その間にCPUの時間を有効活用したい。そのために導入した、みたいな?

江頭:そうですね。

司会者:もし出せたらでいいんですけど、なにか具体的な実装箇所を思いついたりしますか? ちょっと言えなかったら大丈夫ですが。

江頭:今使っているところとか、あとは、うーん……。例えば1個メッチャ重い処理があったとして、ユーザーのレスポンスには影響を及ぼしたくないし、失敗してもユーザーに返さなくていいという処理とかは、普通のAPIの実装でも入っていたりします。

司会者:そうなんですね。

江頭:なんかサブ処理というか。

司会者:なるほど、ありがとうございます。

テストコードを書く難易度はどうだったか?

司会者:(質問を見て)これ、いいですね。かなり「いいね」もついています。「goroutineで非同期処理が多そうでしたが、テストコードとかを書くのはやはり非同期だとけっこう難しいんじゃないかなと思います。そこらへんはどうですか?」

江頭:そうですね。基本的にはgomockを使ってモック化して書いていて、今回のようなケースだとそれでいけています。もうちょっと複雑なものもあったりして、フレイキーテストとかになっちゃうこともあるのですが、でもだいたいはgomockとかを使ってうまい感じに書けていますね。

司会者:けっこうそれでカバレッジとかもきちんと出せて、プロダクション……。

江頭:そうですね。

司会者:なるほど。ありがとうございます。

LRUとTTLの両方が必要な理由

司会者:次に来ているのが、これはキャッシュのお話ですね。「LRUとTTLの両方が必要な理由って何なんでしょうか?」。補足として、「LRUがあればTTLはいらないんじゃないか?」というコメントが来ています。

江頭:ABEMAの場合、リニア配信がある都合上、最新コンテンツを推したいケースや、刻一刻と見られるコンテンツ・見られないコンテンツが変わっていくので、けっこうデータの鮮度が重要です。LRUとかでキャッシュしてしまうと、いつそのキャッシュが切れるかのハンドリングがしにくくなるので、TTLも欲しかったという。

司会者:どっちも必要だったということですね。なるほど、ありがとうございます。

Go特有の「ここがうれしかった」ポイント

司会者:(質問を見て)これはいいですね。「ほかの言語ではなく、Goを採用する上で期待していたこと、そして実際にそのバリューは果たされたのかを教えてほしい」とのことですが、なにかGo特有の「ここがうれしかったな」みたいなポイントはあったりしますか?

江頭:2つあります。1つがやはり、先ほどのgo-redisとかじゃないですが、ライブラリがけっこう充実していること。特にサーバーや大規模アクセスに耐え得るようなライブラリが充実しているところと、あとはやはりgoroutineが強いなと思います。

具体的に、今回は最初Pythonで実装しようとしたのですが、ライブラリの選定から慣れていないので、メチャクチャ時間がかかってしまいました。やはりそういう時もGoはこういうのがメチャメチャ充実しているなと思いましたね。

司会者:なるほど、そうですね。

メンバー2人で2日で開発

司会者:これは僕個人の質問ですが、やはりGoはチーム開発でも導入しやすいと思いますが、今のプロキシサーバーとかは何人で開発されたんですか?

江頭:これは2人です。

司会者:あ、2人か。少ない(笑)。

江頭:しかもこれは実際2日で作っています(笑)。

司会者:すごい、2日(笑)。けっこう突貫だった感じですかね。

江頭:そうです。まぁ背景がアレなので、突貫で作りました。

司会者:でも、それでもきちんとプロダクションに耐え得る運用もできるし、先ほど言ったとおり、テストコードとかもきちんと書いてリリースできた感じですかね。

江頭:そうですね。テストコードもきちんと書きました。

司会者:それはまぁ、もちろん本人のエンジニア力もあると思うんですけど(笑)、そういった中でもやはり書きやすさや導入しやすさでやりやすかったということですかね。ありがとうございます。

Yatagarasuを介するアクセスとキャッシュを利用する場合で、レイテンシの差は出るのか?

司会者:次に来ているのが、またキャッシュのお話です。「Yatagarasuを介するアクセスとキャッシュを利用する場合で、レイテンシの差はけっこう出てくるものなんでしょうか?」

江頭:けっこう出てきますね。

司会者:具体的な数値はたぶん言えないと思いますが、体感的にもけっこう出てくる?

江頭:エンドユーザーが感じるのはほぼないとは思いますが、インメモリキャッシュだと本当に1msecとか数msecで返せるので、もうレイテンシはめちゃくちゃスピードが違いますね。

司会者:ありがとうございます。100ミリ……1秒以上遅らせると、ユーザーは体感で「遅いな」と思うとよく言いますが、そこらへんもぜんぜんないキャッシュの速度で返せている感じですかね。

江頭:そうですね。

司会者:ありがとうございます。

短期間で作る必要があったためシンプルなgobを活用

司会者:本当に(質問が)いっぱい来ているのですが、次はライブラリのお話を拾います。「先ほど、gobを使っているというお話がありましたが、パフォーマンスイシューとかは開発中や運用中は特に見つからなかったですか?」 

江頭:そうですね。gob以外もいろいろな方が作ったエンコーディングフォーマットやライブラリがあるのですが、たぶんデータサイズなどにも依存するので、どれがいいとか、どれが微妙とかを判断するのはけっこう難しくて。

先ほど短期間で作ったという背景も説明しましたが、とにかく短期間で作る必要があったという点で、パフォーマンスもそんなに悪くないし、シンプルなgobを使ったという感じですね。

司会者:なるほど。やはりGoを使う人たちは、すごくフルスタックなライブラリとか豪華なライブラリとかより、シンプルなライブラリを使う印象がありますよね。ほかのも使っている中で、ライブラリの選定理由はけっこうそこが多かったりしますか? 

江頭:そうですね。あるかもしれないですね。

司会者:なるほど、ありがとうございます。

RedisのRingクライアントを選定した理由

司会者:次が、「先ほど、RedisのRingクライアントを使っているという話がありましたが、選定理由はなにかありますか?」

江頭:そうですね。いろいろな背景がありますが、Google CloudのRedisだと基本的には、HA構成、スタンダード構成、1個メインがあったらリードレプリカが置ける構成、単純に1台だけある構成です。

今回、HA構成とかだとメインが落ちた時に若干ダウンタイムが発生してしまいます。そういったことも避けたいのですが、Redisクラスタの選択肢にGoogle Cloudがないので、使いやすいRingクライアントを採用した感じです。

司会者:はい。ありがとうございます。

Goだからこそ感じたパフォーマンス面のデメリットはなし

司会者:だいぶ捌けてきたのですが、そうですね……これはちょっとベンチマークのお話ですね。インフラのほうじゃないのですが、「各関数のパフォーマンスのプロファイリングに関して、GoのBenchmark関数などを活用していますか?」

江頭:そうですね……。この機能では使いませんでしたが、たぶんこの次のセッションでは具体的に使っています。

司会者:なるほど。じゃあ次のセッションをお楽しみにしていただければと思います。

という感じで、あとはだいたい同じような……あ、じゃあちょっと最後に1個だけ、すみません、拾わせてください。「逆に、実際に運用してみてGoだからこそ感じたパフォーマンス面のデメリットはありましたか?」 特になかったですかね? 

江頭:そうですね、特には。

司会者:特にはない。ありがとうございます。じゃあプロダクトでもガンガン高負荷を流せるよという感じ?

江頭:そうですね。

司会者:ありがとうございます。では、いったんここらへんで質問の時間は区切らせていただこうかなと思います。あらためて、江頭さん、発表をありがとうございました。

江頭:ありがとうございました。

(会場拍手)