はじめまして、テクノロジー本部の布川です。そろそろ入社して一年が過ぎようとしています。
本記事ではLIFULL HOME'Sのページ高速化PJの一環として行われた、マイクロサービス間のhttp keepaliveを自前で有効化する取り組みについてお話しさせていただきます。
同じように高速化を行おうとしている方々の参考になればとても幸いです。
背景と目的
プラットフォームGと高速化について
2021年6月にGoogleがWebサイトの性能指標としてCore Web Vitalsを導入してから、サイトの読み込みや操作に対する応答の速度、レイアウトの安定性といった指標がSEOに影響を与えるようになりました。私の所属するプラットフォームGの高速化チームは、LIFULL HOME'Sの主要なページのパフォーマンスを監視しながら、主にサイトの読み込み速度の改善に取り組んでいます。
サイトの読み込み速度の改善方法は、大きくフロントエンドからのアプローチとバックエンドからのアプローチの二種類に分かれます。特に後者について挙げられる手段は、システムの構成や使われている言語やフレームワークなどのさまざまな条件に左右されるため多岐に渡りますが、どれも「できるだけ早くブラウザにレスポンスを返す」ことに繋がっています。
今回の話は、バックエンドからのアプローチに寄ったものになります。
LIFULLにおけるパフォーマンス改善に向けた取り組み
弊社の提供する不動産ポータルサイトであるLIFULL HOME'Sは、KEELと呼ばれるkubernetesベースのアプリケーション実行基盤の上で連携する複数のコンポーネントによって構成されています。システムを構成する要素が複数ある場合はボトルネックの特定が難しくなるため、横断的なパフォーマンス改善が難しくなってしまう側面がありますが、KEELがLIFULL HOME'Sに向けて提供しているログの横断検索機能やリクエストの分散トレーシング機能を活用することで、効率の良い改善活動を行うことができています。
KEELの上ではLIFULL HOME'Sに直接関係するサービスの他にもたくさんのアプリケーションが動いており、その一つ一つにパフォーマンス改善に関わる機能以外にも、自動デプロイフローやサービスメッシュ、セキュリティなど運用上必要になるさまざまなものが提供されています。
ボトルネックの発見
KEELでは、サービスメッシュとして導入しているistioが各マイクロサービスに付加するistio-proxyによって、サービスが通信時に必要とするトラフィックの管理などに関する機能を提供しています。しかしリクエストを直列に処理するモデルの関係上、一部のマイクロサービスではパフォーマンスの観点からistio-proxyの付与を無効にして、直接相手のサーバとやり取りしていることがあります。
LIFULL HOME'Sではフロントサーバの裏でAPIサーバやページ毎のリソースを集約するBFFが動いていますが、現状これらのサーバではistio-proxyが無効化されています。そのためこれらのサーバ間で通信を行う際は、リクエストのたびに接続を張り直したりリトライ処理が正しく行われていなかったりといった問題があり、特に都度接続を張り直すことに関してはサイトの読み込み速度の低下に繋がっていることが考えられました。そこで、httpクライアントの実装やサーバ側の設定を適切に行うことで、istio-proxyの無効化により失われていた機能のうちhttp keepaliveやリトライ機能を自前で有効化することにしました。
課題と対応
マイクロサービス間のhttp keepaliveを自前で有効化する(=リクエスト/レスポンスのConnectionヘッダにkeep-aliveを指定する)にあたり、解決すべき課題がいくつかありました。
- 内外のコンポーネントへの影響調査
- コネクションプールの作成
- スケーリング時の対応
その中でも主な課題について、それぞれの詳細と行った対応について説明していきます。
内外のコンポーネントへの影響調査
http keepaliveを有効化するとクライアント-サーバ間で一度張られたtcp接続は一定期間保持されるようになりますが、この時ある時間帯に張られる接続の数は減少するものの、あるタイミングで同時に張られる接続の数は増加します。そのため、サーバが同時管理する接続数が現状からどの程度増え、それが許容できる範囲なのか否かを調査しました。
その結果、APIサーバで一つのpodに対して同時に張られる可能性のある接続の数と比較して、それぞれのpodにいるnginxが管理できる接続数に十分余裕があることを確認しました。
また、APIサーバに直接接続してくる他のサービスのhttpクライアントが、APIサーバ側のhttp keepaliveが有効な場合は接続を使い回すようになるのか、その際接続が切れたら繋ぎ直すなどの必要な処理を正しく行えているのかといった他サービスの調査も念の為行いました。
実際、istio-proxyを無効にしてAPIサーバに直接アクセスしてくるマイクロサービスは他に2つほどあり、それぞれのhttpクライアントはnode(v14), python(v3.7)のものを使っていましたが、以下のように問題がないことを確認しました。
コネクションプールの作成
先ほどの図のようにBFFにおいては一つのpodの中で複数のpassanger worker processが動いており、それぞれのworker processがフロントエンドサーバから受け取った一つのリクエストを処理しています。このworker processがスレッド並列でAPIサーバにリクエストを投げる際に張る接続を、http keepaliveによって一定期間保持して使い回そうとしています。
この時、あるworker processがAPIサーバに対して逐次的なリクエストしか行わないのであれば、worker process毎に保持する接続は一つで事足ります。しかし、worker processがAPIサーバに対してスレッド並列的にリクエストを投げる場合、worker process毎に複数の接続をスレッドセーフな状態で保持する必要があります。
これについては、worker process毎に一つのコネクションプールを保持し、リクエストを投げる際はプール内の使用可能な接続を使い回せるようにすることで解決しました。BFF上で動くアプリケーションはrubyによって記述されていますが、rubyの標準的なhttpクライアントであるnet/httpはコネクションプールの機構を持たないため、新しくGemを導入して実装を行いました。
スケーリング時の対応
現在のkubernetesの運用では、事前に定義したHorizontal Autoscaling Policyに基づいて15秒毎にスケールするか否かの判断を行います。この時、APIサーバのスケールインによって接続先のホストがいなくなった場合やスケールアウトによって接続可能なホストが増えた場合のハンドリングを、BFF上のアプリケーションで正しく行う必要がありました。
スケールイン: APIサーバ側のpod数が減少するとBFF側で保持しているいくつかの接続では通信相手がいなくなるため、繋ぎ直しを行う必要があります。net/httpはあるtcp接続を使い回すにあたり自分もしくは相手からの終了処理によってソケットが閉じられていた際は次回使用時に自動的に再接続を行うため、この仕様とkubernetesのルーティング機能によってまだ生きているAPIサーバのpodと接続を張り直すことができました。
スケールアウト: APIサーバ側のpod数が増加しても、BFF側で新しく増えたpodに対して一部の接続を張り直すよう能動的に行動を起こすことはできません。また、kubernetesのデフォルトの通信機構ではkube-proxyによってラウンドロビンなリクエストの振り分けが行われているため、そのままでは新しく生成されたpodへの接続数が少なく偏ってしまいます。この問題についてはAPIサーバ側でkeepalive timeoutをスケール頻度である15秒に調整することで、BFFが定期的に接続を張り直せるようにしました。こうすることによって定期的な接続のバランシングが行われ、APIサーバの一つのpodあたりの接続数はほぼ均等に保たれます。
改善の様子
以上の取り組みによってistio-proxyが無効なマイクロサービス間のhttp keepaliveを有効化することができ、以下のように接続の張り直しがリクエストの度に行われなくなりました。
これによって、特に多くのリソースを要求するページにおけるサーバーレスポンスタイムが以下のように改善しました。
上のグラフは開発環境で各条件ごとに調査した結果ですが、本番環境でも同ページのリリース前日とリリース翌日の24時間のサーバーレスポンスタイムの平均を比較すると、34ms程の改善が見られました。程度に差はあれど裏側でリソースを要求するLIFULL HOME'Sの全ページが今回の施策の恩恵を受けることになります。
また、直接的なパフォーマンスの改善の他にも、active connection数の低減などといった効果が見られました。
まとめ
マイクロサービス間のhttp keepaliveを自前で有効化する取り組みについて、接続を使い回すことに伴ういろいろな問題に対処しながら実装を行い、パフォーマンスの改善に繋げたお話でした。ここまで読んでいただきありがとうございました。
今回の施策によって生まれた改善は小さいように見えますが、このような小さな改善を積み重ねる過程でノウハウを蓄積させ、次の改善やパフォーマンスの劣化防止に繋げていくことが重要だと感じます。また、タスクの中ではkubernetesがいかに多くのことをやってくれているかや、インフラやミドルウェアに対する理解が高速化の文脈でも重要になってくることを感じました。
社内の様々な人に助けてもらいながらリリースに漕ぎ着けたこちらのタスクですが、今後は今回の経験と反省をもとに更なるパフォーマンス改善に取り組みSEOに強く良い体験のできるサイトに近付けて行ければと思います。
最後に、LIFULLでは共に成長できるような仲間を募っています。よろしければこちらのページもご覧ください。