SmartNewsのサーバサイド改善

Marc Brandenburg氏(以下、Marc):(日本語で)みなさん、こんばんは。

会場:こんばんは。

Marc:(英語で)プレゼンテーションは英語で行いたいと思います。まず最初に弊社の尾形が、SmartNewsの一般的なアーキテクチャやインフラについてお話させていただきました。私はSmartNewsでこれまで行ってきたあるプロジェクトについてお話しようと思っております。

最初に自己紹介をさせてください。私はMarc(マーク)と申します。ドイツ出身です。日本に来て3年、スマートニュースで働き始めて3ヵ月たちました。Infrastructureチームで、他のsquadが開発に使える基盤システムを提供しています。

言語はモダンなものが好きで、主にKotlinを使用しています。また、DDD(Domain-Driven Development)やClean Architectureといったソフトウェアアーキテクチャの方法論についても興味があります。今日はこれらについても触れていきます。

私が今日お話しするのは、スマートニュースで初めて任されたプロジェクトについてです。既存のシステムのパフォーマンスが芳しくなく、機能面についても改善の余地があったのでその改善にあたりました。その取り組みについてご紹介いたします。

そのシステムというのは、我々のA/Bテストシステムのことです。ここにいらっしゃるみなさんはA/Bテストとは何かご存じかと思いますが、例えばプロダクトチームが新しいフィーチャーをリリースする際に、テストグループを作り一部のユーザーに公開して十分機能していたら、全体にリリースするといったものです。

ユースケースについてスライドの左側(ユーザをアサインする箇所)を見ていきます。本日は、プロダクト側でどのように結果を分析するか(プロダクトサイドのユースケース)にはフォーカスしません。簡単に言うと、プロジェクトチームが新しいテストを作成し、テスト終了後に分析チームがその結果に基づいて分析をします。

このようにして私たちはユーザーにA/Bテストを通じて機能を提供します。基本的に全てのAPIが、基盤となるこのユーザ情報のメソッドをコールすることによって、ユーザーがA/Bテストのどちらにアサインされたかを業務ロジック側で知ることができます。

このシステムはプロダクト全体において非常に重要となるので、システム全体のパフォーマンス、スケーラビリティ、稼働率への影響を非常に重要視します。

そのためのQuality Attributesを用意しています(resp. time < 200ms, 99.99% ops rate etc)。パフォーマンスとはたとえば、ニュースフィード全体をリフレッシュする際のレスポンスタイムなどです。また、アプリ全体のさらなる成長を見越してスケーラブルであることも重要です。

稼働率については、データの整合性などとトレードオフの関係にあります。ただし、我々はたとえば金融系のシステムを提供しているわけではないので、ここにおいてはシステムが安定して利用できることを可能な限り重視します。

A/Bテストで判明したシステムの問題

では次のスライドです。私が入社した直後のスマートニュースのシステムがどんなものだったのかを見ていきましょう。

また図の左側から見ていただきたいのですが、基本的に標準的な作りになっています。クライアントサイドがデータを取得するためにAPIを呼び出します。サーバサイドでユーザのA/Bテストの割当を計算するためにはデータベースを検索する必要がありました。例えば、「この人はAndroidを使っているので、Android用のテストに割り当てる必要がある」といった具合です。割当処理が終わったら、後から分析チームがその情報を利用できるようにデータベースを書き換えて、レスポンスを返します。

このシステムにはいくつか問題がありました。第一に、そのデータベースの読み込みが大量となるワークロード(read-heavy)に最適化されていなかったということです。ご想像の通り、プロダクトチームが毎日A/Bテストを実施したりするとシステムのパフォーマンスがどんどん悪くなるといった感じです。

つぎに、整合性がもっとも重要である場合はデータベースを書き換えてからレスポンスを返すと思うんですが、我々のアプリのようにアベイラビリティやパフォーマンスも重要であるユースケースではこのトレードオフを考慮する必要がありました。

次に、このデータベースがプログラムだけではなく分析チームやプロダクトサイドからも依存されていたという問題です。APIのパフォーマンスを高めるためにデータベースやスキーマなどを新しいものに移行したい場合、彼ら一人ひとりと議論しなければならないというおなじみの問題です。

それから、この図を見てわかると思いますが、A/Bテストの割当に関するロジックが複数の箇所に散らばっており変更に対する柔軟性が失われていました。

どうやってRedisのメモリ消費を最適化するか?

次に、これらの問題をどう解決したかについてお話しします。

先程より複雑に見える図ですが、割当情報をデータベースから直接読み込むのではなく、読み込むのに特化しているキャッシュから取得するように変更しました。ユーザーIDに対して割当情報を取得するのが主な目的なので、(KVSベースの)キャッシュを使ったほうが非常に高速です。

それから、下のほうに非同期処理とありますが、先ほども触れたとおりデータベースへの書き込みを待つより早くレスポンスを返すための実装を行いました。

また、最初に触れたClean Architectureの原則にアーキテクチャ全体を合わせました。

キャッシュ層の選択としては、Redisが良い選択肢に思えます。システム全体として採用事例も多く、運用のノウハウもありました。しかし、採用にあたって2つの疑問がありました。

1つは「どうやってメモリ消費を最適化するか?」ということです。メモリを使い切ると新しいものを追加する必要があるので、どのようにデータを保持するかは非常に重要です。もう一つはデータの耐久性の問題です。Redisはオンメモリベースのアーキテクチャなので、もしクラッシュがあると全てのデータを失うことになります。

(スライドを指して)そこで、ベンチマークを取ってみました。ここで、以前は知らなかった発見が1つありました。値として何らかのデータ構造を格納する必要がありますが、我々のユースケースにおいて最も適しているのは、ユーザIDをキーにしてA/Bテストの一覧をSetで持つことです。もし、テストのID一覧のみをintegerのSetで持つと、要素数が一定以下の場合、内部の最適化がかかり大幅にメモリ消費量が少なくなりました。

データの耐久性の調査

次にデータの耐久性についても調査を行いました。

RedisにはAppend-only File Persistenceというモードがあり、設定によるタイミングでデータをファイルシステムに書き出すことができます。毎回書き込むこともできますし、1秒に1度書き込むこともできます。

こちらがパフォーマンスの比較です。全体としてデータ量が増えると処理にかかる時間は増えていきますが、タイミングによらず全体的なパフォーマンスに大きな差はありません。

このような技術選択の前後には、フィードバックセッションという、メンバーからアイディアやフィードバックをもらえる時間があります。バックエンドやSREのチームメンバーが集まって議論しフィードバックや改善へのアイデアをもらったり、調査のアップデートを話したりします。これはとても良い取り組みだと思っております。

Kotlinによる実装と、Coroutinesの利用

それでは、実装についてお話をしましょう。

Kotlinはとても良い言語だと思っており気に入っているのと、前職ではJavaを使っていたこともあり新しくチャレンジしたかったので、Kotlinによる実装を提案し、チームから承諾されました。

それからCoroutinesのパッケージを利用しています。最近1.0か1.1が出ていると思います。従来よりエレガントに非同期処理を書くことができ素晴らしいと思っています。

Kotlinを使っているのでテストフレームワークにはSpek Frameworkを使用しています。システム全体はSpring Boot 2で稼働しています。では、これから私がどのように複数のモジュールをClean Architectureにのっとって構成したか、その原則についてお話しします。

システムの中核をなすのはEntityと呼ばれる概念です。EntityにA/Bテスト割当ロジックなどの複数の業務ロジックが含まれます。これらの業務ロジックを全てをEntityにいれる利点としては、これらはビジネス上コアなロジックであり頻繁に変更されるわけではないからです。

これらは外部システムに依存しないので純粋関数のように副作用がないものを含むことができます。これらによって、data-drivenにテストしやすい構造を保つことができます。入力を定義し出力を検証すれば良いんです。

次にユースケースの話です。

ユースケースによりアプリケーションをモデル化できます。先ほどお見せしたダイアグラムに少し似ていると思います。こちらのユースケースでは、非同期のデータフローをモデル化しています。Corutineはこのようなモデルに非常に相性が良いです。

Deliveryのユースケースを見ると、フロントエンドからのAPコールに対してRedisからデータを取得します。業務ロジックのためにEntitiesを利用し、結果を返します。このように、ユースケースを定義することでモジュール化を促進できます。一つのユースケースを一つのクラスに実装することで、単一責務の原則を守ることができます。

Redisなどの外部システムに対してはインターフェイスを経由してアクセスします。そうすることで、例えばRedisを将来使わなくなったとしてもロジック側の修正の必要はありません。

このようなものを含むテストはEntity単体のテストより複雑なので、data-drivenな手法よりもbehavior-drivenな手法でテストします。「このようなデータベースの状態において、このメソッドはこのような値を返す」という具合です。

Canary Deploymentの効果

次はGatewaysについてです。これらは、ユースケースのインターフェイスについて実際の実装を与えるものです。たとえば、RedisのGatewayは複数のユースケースから利用される実際の実装を持ちます。これらはインターフェイスを経由して利用されるので、将来的に実装を差し替えることができます。

では、デプロイメントに移りたいと思います。設計と実装が終わったら、それを本番にデプロイする必要があります。

デプロイ前にはパフォーマンステストを行います。本番環境と同等の環境をstaging環境に再現し、Locustというツールでパフォーマンステストを行いました。これは、テストしたいendpointに対してユーザからのアクセスをシミュレーションしてくれるものです。

これにより、Gatewaysの先にある各リソースのキャパシティが十分か、リソースにリークがないかなどを検証することができます。

また、我々はCanary Deployment(カナリアデプロイメント)を採用しています。全体のノードにデプロイを行う前に少数のカナリーノードにデプロイを行い、ユーザに対する影響や他システムへの影響を最終的に検証します。カナリーノードではNewRelic(APM)が作動しており、システムの各部部の性能を視覚的に確認することができます。

このシステム刷新の成果として、新システムでは旧システムに対してピーク時の負荷が半分以下になりました。旧システムではピーク時に性能劣化が見られたのですが、新システムではピーク時の負荷もその他の時間帯と同じ程度になりました。また、グラフに現れないものとしては、旧システムに存在したデータベース負荷を抑えるためのrate limittingの仕組みを取り除くことができました。

ご清聴ありがとうございました。

繰り返しになりますが、私たちは一緒に働く仲間を募集しています。このスライドを見て、スマートニュースでの開発に興味をもっていただけたら、私やほかのメンバーにお声がけください。

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