こんにちは、坂内理人(@rihib_dev)と申します。Product Div.のProduct BackendチームでJOBインターンとして約1ヶ月間(11/6~11/29)、ABEMAのバックエンドの開発に携わりました。
期間中は、主に推薦システム周りの開発を行い、いくつかのタスクをやらせていただきましたが、この記事では特にABEMAのスパイク時の累計メモリ割当量を2TB削減したタスクについて焦点を当てて紹介していきます。
ABEMAの人気シリーズ『今日、好きになりました。』
みなさんは『今日、好きになりました。』(以降『今日好き』と表記)というABEMAの番組をご存知でしょうか。現役高校生たちの恋愛模様を映す恋愛リアリティ番組で、ABEMAの中でも有数の人気コンテンツです。
毎週月曜日の22時に放送されるのですが、リアルタイム視聴が多いのが特徴です。そのため、22時前後の短い時間幅で大量の視聴者が流れ込んでくるので、トラフィックのスパイクもとても大きなものになります。
ABEMAはKubernetes上で動いていますが、通常のオートスケーリングだけでは『今日好き』の急激なトラフィックの増加に対応しきれないので、KEDAというイベント駆動のオートスケーラーを使って月曜22時にあわせてCronを使ったオートスケーリングを行っています。
そのような工夫により現在に至るまで問題なくトラフィックを捌くことができていますが、システムをより安定的に動かすためにはスパイク時のリソース消費量を抑えることが重要です。
スパイク時のプロファイルで問題を発見
期間中はインターン生1人につき1人のトレーナーが面倒を見てくださるのですが、トレーナーにモニタリングにも興味がありますと伝えたところ、ABEMAのモニタリングの一部もやらせていただくことになりました。ABEMAでは可視化ツールとしてGrafanaを使っています。
その一環で、何か改善できる箇所を見つけられるのではないかと思って月曜22時前後の期間に絞ってプロファイラを見ていたところ、スパイク時に一番大きな動的メモリ割り当て(累計)が発生しているメソッド(`Rerank`メソッド)を発見しました。
トレーナーの方にそのことを話したところ、改善できるかどうか調査してみることになりました。予定していたメインのタスクではなかったにも拘らず、前向きにやってみようと背中を押してくださったのはとても有り難かったです。
原因の調査
調査のために問題のメソッドのコードを見てみたところ、下記のようになっていました(ここでは実際のコードではなく、説明用のコードを載せています)。
func Rerank(module Module) Items {
itemMap := make(map[string]Item, len(module.Items))
for _, item := range module.Items {
itemMap[item.ID] = item
}
result := make(Items, 0, len(rerankedIDs))
for _, id := range rerankedIDs {
if item, ok := itemMap[id]; ok {
result = append(result, item)
}
}
return result
}
ここでの`module`(モジュール)と`module.Items`(アイテム)はそれぞれABEMAの下記の部分を指しています。この問題の`Rerank`メソッドはABEMAで使われている推薦システムのロジックの一部であり、下記のようなランキングを表示するモジュールなどで、算出したスコアの高い順にアイテムを並び替えて返すメソッドになっています。
そのため、ランキング順に並んでいるアイテムのIDのリスト(`rerankedIDs`)をもとにアイテムを並び替える処理を行う必要があるのですが、その際に検索速度を速めるために一度マップ(`itemMap`)に変換をしています。マップに変換するとその分だけ追加のメモリ使用が発生することになります。さらに`Rerank`メソッドの引数にはモジュールが値渡しされており、このこともメモリ割当量が大きくなる原因だと考えました。
またランキングのモジュールに表示をするアイテムの数はせいぜい数十個ほどであり、表示するアイテムの全候補を並び替えて全て返す必要はありません。なのでもしも大量のアイテムが`module.Items`に入っている場合は、実際に並び替えて返すアイテム数を数十個に抑えることでもメモリ使用量を削減することができます。
これを確かめるために、本番環境での`module.Items`のサイズを出力する検証用のログを入れて確認したところ、実際に表示する数よりもとても多いアイテムが返されていることがわかりました。
話が逸れますが、ABEMAではトランクベース開発を行なっているので、マージしたコードは即座に本番環境にリリースされます。変更を加えやすいため、今回のような検証もスピード感を持って行うことができました。サイバーエージェントで行われているトランクベース開発についてもっと知りたい方は過去に書かれた記事があるのでこれを参照してください。
改善
メモリ割当量を抑えるために、主に下記の修正を行いました。
- モジュールをポインタ渡しにする
- マップなどを使わず、追加のメモリを使わないようにする
- 上位50個のアイテムのみを並び替えて返すようにする
func Rerank(module *Module) Items {
limit := 50 // トップ50のアイテムを取得して返す
topModules := make(Items, 0, limit)
for _, id := range rerankedIDs {
item, ok := lo.Find(module.Items, func(i Item) bool {
return i.ID == id
)
if !ok {
continue
}
topModules = append(topModules, item)
if len(topModules) >= limit {
break
}
}
return topModules
}
GoではGCが重くなるので安易に値渡しの代わりにポインタ渡しをすることは推奨されていませんが、サイズが大きくなる場合はポインタ渡しを使った方が良いとされています(Go Style Decisions)。
また、マップなどの追加のメモリを使わないようにするため、50個の上位モジュールを見つけるために50回線形探索をするようになっています。当初は1回の探索だけで上位50個のモジュールを見つける実装もしたのですが、コードが複雑になることやどちらにせよCPU負荷に対する影響は軽微だったことから、保守のしやすさを優先してこのような実装にしました。
今回の変更はABEMAのホーム画面にも影響するものだったので、リリースは慎重に行いました。ユニットテストを追加するのはもちろんですが、まず自分とトレーナーのユーザーIDの時にだけ、修正版`Rerank`メソッドを用いるようにした変更をリリースして、ロジックに問題が発生していないかを確認しました。
問題がないことを確認した後に、修正版`Rerank`メソッドを全ユーザーに対してリリースしました。ABEMAではフィーチャーフラグを使用したカナリアリリースを行なっているため、少しずつユーザーに対してリリースをしていき、途中でエラーレートが高くなるなどの問題が発生したらすぐに切り戻せるような体制になっています。サイバーエージェントでのフィーチャーフラグの利用について知りたい方はこちらの記事を参照してください。
結果
変更をリリースしたのが月曜日で、その夜は配信に問題が発生していないかどうか内心ハラハラしながら『今日好き』を視聴しました。結果的には特に障害などは発生せず、視聴やロジックにも影響が出なかったようなので安心しました。
肝心のメモリ割当量がどれくらい削減されたかについてですが、朝に出社してGrafanaのヒーププロファイルを確認したところ、これまで一番メモリ割当量の多かった`Rerank`メソッドが大きく順位を下げ、月曜22時前後の20分間のスパイク時の累計メモリ割当量が従来と比べて2TiB削減されたことがわかりました。
まとめ
ABEMAの『今日好き』のスパイク時に`Rerank`メソッドのメモリ割当量がとても大きくなることを見つけ、追加のメモリを使用しないようにコードを書き直したところ、スパイク時の約20分間の累計メモリ割当量を従来と比べて約2TiB削減することに成功しました。
最後に
実はここで紹介したタスク以外にも、年末施策に向けた実装や、AlloyDBのカラムナエンジンの導入検証、KubernetesマニフェストやTerraformの修正など様々なタスクを行いました。
技術的な話以外では、インターン期間中は毎日色々な部署の方々とランチや懇親会に行ったり、ABEMA内のキックボクシングサークルの練習に飛び入りで参加させてもらったりと、とても濃い1ヶ月間を送ることができました。
ABEMAのProduct Div.の皆さんをはじめ、1ヶ月間毎日面倒を見てくださったトレーナーの坂田淳樹さん、色々とサポートをしてくださったEMの菅俊弥さん、そしてチームの皆さんのおかげで1ヶ月間やり切ることができました。ありがとうございました。今後も1人のエンジニアとして技術とユーザーに誠実に向き合っていきたいと思います。
1ヶ月間本当にありがとうございました!!