Cloud Run のセッション アフィニティで応答性を向上
Google Cloud Japan Team
※この投稿は米国時間 2022 年 7 月 26 日に、Google Cloud blog に投稿されたものの抄訳です。
Google はこの 6 月に、Cloud Run サービスのセッション アフィニティのプレビュー版をリリースしました。セッション アフィニティを使うと、クライアント固有の状態をコンテナ インスタンスに保存するサービスの応答性が向上します。このブログでは、セッション アフィニティがどんなときに役立つかをさらに掘り下げ、Cloud Run でどう機能するかをご説明します。
セッション アフィニティを有効にすると、同じクライアントからのリクエストは同じコンテナ インスタンスにルーティングされます(利用可能な場合)。セッション アフィニティを使うことでメリットが得られるサービスの例としては、コンテナ インスタンスでローカル キャッシュを多用するサービスや、WebSocket のような長時間接続を使用するサービスなどがあります。
セッション アフィニティをすぐに試してみたいと思われる方は、Cloud Run のドキュメント内のセッション アフィニティを設定するをご覧ください。
セッション アフィニティについて理解する際、サーバーサイド セッションについてはいったん忘れてください。セッション アフィニティを使用しても、サーバーサイドのセッション データをコンテナ インスタンスに直接永続的に保存することはできません。もし使用した場合、お客様から「たびたびログインし直さないといけない」、「ショッピング カートの中身が消えてしまう」といったクレームが来ることになります。詳しくは後ほど、「サーバーサイドのセッション データを永続ストアに保存する」でお話しします。
Cloud Run とは
まず、馴染みのない方のために、Cloud Run の簡単な紹介をしたいと思います。(すでにご存じの方は、「セッション アフィニティの例」までスキップしてください。)
Cloud Run は、フルマネージド コンピューティング プラットフォームで、Google のスケーラブルなインフラストラクチャ上で直接コンテナ内のコードを実行できます。Cloud Run を設計する際に目指したのは、デベロッパーのさらなる生産性の向上です。デベロッパーはコードを書くことに集中し、実行は Cloud Run に任せることができます。
Cloud Run 上でコードを実行する方法は 2 つあります。サービスを使用して、ウェブ リクエストまたはイベントに応答するコードを実行する方法と、ジョブを使用して、完了とともに終了するコードを実行する方法です。セッション アフィニティはリクエスト ルーティング機能であるため、サービスのみに適用されます。
セッション アフィニティの例
Cloud Run は、負荷分散のために追加のコンテナを起動することによって、入ってくるすべてのリクエストを処理します。セッション アフィニティがない場合(デフォルトの状態)、あるクライアントからのリクエストには、下記に示すように、どのコンテナ インスタンスでも対応できます。
セッション アフィニティを有効にすると、同じクライアントからのリクエストは同じコンテナ インスタンスにルーティングされます(コンテナ インスタンスが利用可能で、リクエストを処理するキャパシティがあることが前提)。
セッション アフィニティの仕組み
セッション アフィニティの仕組みを知り、活用する方法を学びましょう。Cloud Run は、クライアントからの最初のリクエストに応答するとき、生成されたセッション アフィニティ Cookie をレスポンス ヘッダーとして追加します。Cloud Run は、この Cookie から、リクエストを実行したコンテナを特定できます。
同じクライアントからのそれ以降のリクエストに対し、Cloud Run はセッション アフィニティ Cookie がリンクしているコンテナ インスタンスにリクエストをルーティングします。
クライアントでの Cookie の取り扱い
ブラウザは、Cookie を透過的に処理します。サーバーが Cookie を含むレスポンス ヘッダーを送信すると、ブラウザはそれ以降のリクエストにその Cookie を追加します。
プログラムでリクエストを送信する場合、多くのケースでは明示的に Cookie を管理する必要があります。コードからリクエストを送信する場合、セッション アフィニティ Cookie がすべてのリクエストに追加されているかを確認します。これは、curl などのツールを使ってスクリプトからリクエストを送信している場合にも当てはまります。
WebSocket ストリームは、リクエストと同じようにセッション アフィニティ Cookie を使用します。ブラウザが WebSocket を起動すると、Cookie を含む HTTP ベースの handshake でストリームが初期化されます。今の時点では、意味がよくわからなかったとしても心配はいりません。この後の「セッション アフィニティによる、同じインスタンスへの WebSocket の再接続」で詳しくご説明します。
セッション アフィニティは、ローカル状態を再構築するコストを回避するためのもの
Cloud Run でセッション アフィニティがどのように機能するかをご理解いただけたところで、コンテナ インスタンスでローカル キャッシュを多用するアプリケーションを例に、そのメリットをより具体的にご説明します。この例は、私が以前構築したあるアプリケーションに基づいています。
現在あなたは、ユーザーがクエリの結果を可視化できるダッシュボードを構築しているとします。このダッシュボードで、ユーザーは結果を表示し、フィルタを使ってさらに詳しい情報を確認できます。あなたのアプリケーションは、あまり性能の良くない古いバックエンド システムに対してデータを要求しています。一度に多くのクエリを送信することはできず、レスポンス タイムも数十秒程度かかります。
ユーザーに優れたエクスペリエンスを提供するために、ダッシュボードの応答性を高めるのに役立つキャッシュを導入しました。アプリケーションが低速のバックエンドにクエリするたびに、その結果がメモリに保存されます。ユーザーがデータをフィルタすると、コードは低速のバックエンドに追加のクエリを送信することはせず、インメモリの結果セットにフィルタを適用します。
通常はクライアントごとに違うデータが表示されるため、ダッシュボードの結果はクライアント固有のものです。簡単に言うと、アプリケーションは、クライアント ID をキーとし、キャッシュされた結果を値とする大きなルックアップ テーブルをメモリに保存しているのです。幸いなことに、キャッシュの複雑な部分をすべて処理するライブラリが見つかりました。結果を取得しさえすれば、古くなったレコードの無効化やメモリ内の最大サイズの維持はライブラリがやってくれます。
キャッシュがこのダッシュボードで非常に役立っているので、あなたは将来のクエリを予測して結果をプリフェッチすることまで考えています。
サービスの人気は上がってきましたが、ある問題が発生します。サービスへのトラフィックが増加すると、パフォーマンスが徐々に低下するのです。Cloud Run がすべてのリクエストを処理するためにコンテナを追加し始めたことで、インメモリ キャッシュが効果的に使用されなくなるようです。
Cloud Run が受信トラフィックを処理するためにコンテナ インスタンスを追加すると、クライアントはキャッシュされた結果を持たないインスタンスにそれ以降のリクエストを送信します。これにより、アプリケーションはバックエンド システムに対して不必要にデータを再リクエストすることになるのです。
セッション アフィニティは、クライアントのリクエストを毎回同じコンテナ インスタンスにルーティングするよう最善の努力をします。これにより、クエリ結果のキャッシュを有効に利用することができ、問題解決に役立ちます。
似たような例が思い浮かぶことと思います。セッション アフィニティは、ローカル状態を再構築するコストを回避するためのものなのです。
コンテナがリクエストを処理できない場合
リクエストを処理できないコンテナ インスタンスに対するリクエストを受け取った場合、Cloud Run は他のコンテナ インスタンスを使用してリクエストの処理を続けます。
Cloud Run は、新しいコンテナ インスタンスを参照する、更新されたセッション アフィニティ Cookie をレスポンスとして送信します。それ以降のリクエストは、その新しいコンテナ インスタンスによって処理されます。
Cloud Run がリクエストに対応するために別のコンテナを選択するケース
コンテナがリクエストを処理できない理由はさまざまです。その一つとして、コンテナ インスタンスがサービスから削除された場合が挙げられます。Cloud Run のオートスケーラーは、スケールインするとコンテナ インスタンスを削除します。セッション アフィニティはコンテナ インスタンスを維持しません。
別の理由は、コンテナがリクエストを処理するキャパシティと関係があります。Cloud Run は、転送先のコンテナが必ずリクエストを処理できることを確認したうえでリクエストを転送しようとします。Cloud Run はなぜ、セッション アフィニティを解除し、別のコンテナ インスタンスを使用してリクエストに対応することを決定するのでしょうか。例を 2 つ挙げて説明します。
同時実行の制限
そのリクエストを処理すると、コンテナ インスタンスの最大同時実行数を超えてしまうため。Cloud Run は、コンテナが同時に処理できるリクエストの数を制限しています(この構成可能な設定について詳しくは、同時実行に関する説明をご覧ください)。
高使用率の CPU
Cloud Run は、CPU 使用率の高いコンテナ インスタンスへのリクエスト送信を回避するため。
Cloud Run がコンテナ インスタンスの数をスケールアウトするとどうなるのか
Cloud Run は、入ってくるリクエストをすべて処理するために、コンテナ インスタンスを自動的に追加します(これをスケールアウトと呼びます)。スケールアウト イベントが発生しても、既存のクライアントのアフィニティは即座に変化はしません。Cloud Run は、セッション アフィニティ Cookie 付きのリクエストを受け取った場合、常にアフィニティを尊重し、指定されたコンテナ インスタンスにリクエストを配信しようとします。
注意しなければならないのは、スケールアウトが発生するのは、既存のコンテナ インスタンスがリクエストの処理に忙しく、Cloud Run が新しいコンテナ インスタンスを追加するタイミングだと判断した場合であるということです。過負荷状態のインスタンスに届いたリクエストが、新たに起動したインスタンスに移動されれば、既存のクライアントのアフィニティが変化する可能性があります。
セッションの間コンテナの寿命が続くことは保証されていない
セッション アフィニティ Cookie の有効期限は 30 日に設定されていますが、これは Cloud Run がクライアントのために、30 日間コンテナ インスタンスを準備状態に保つということではありません。クライアントがコンテナ インスタンスに対するアフィニティを持つかどうかに関係なく、Cloud Run はコンテナ インスタンスを再起動または削除できます。
ただし、コンテナ インスタンスは、リクエストの処理中もアクティブなままですのでご安心ください。コンテナを止めるには、Cloud Run はまずインスタンスへのリクエスト転送を停止します。そして、SIGTERM シグナルを使ってコンテナに警告を出し、最後に 10 秒の猶予期間ののちに停止します。
サーバーサイドのセッション データを永続ストアに保存する
サーバーサイドのセッション データは、ユーザー セッションに関連するデータです。たとえばショッピング カードの中身や、ユーザーのログイン ステータスなどがあります。これらのデータは、永続ストアに保存する必要があります。
多くのウェブ フレームワークは、デフォルトでサーバーサイドのセッション データをファイル システムに保存します。ローカルの開発環境ではいいのですが、Cloud Run では、セッション アフィニティを有効にしてもこれがあまりうまくいきません。
理由を理解するためには、Cloud Run のセッション アフィニティがベスト エフォート型であることを思い出してください。先ほど説明したように、最初のリクエストを処理したコンテナ インスタンスとは異なるコンテナ インスタンスにリクエストが送信されるのにはいくつかの理由があります。アプリケーションがショッピング カートの中身を最初のコンテナ インスタンスに保存した場合、アフィニティが別のコンテナ インスタンスに移動すると、そのセッション データはクライアントから失われます。つまり、ユーザーのショッピング カートは空になってしまいます。
Google Cloud では、サーバーサイドのセッション データを保存するために、いくつかのオプションがあります。そのうちの一つは、フルマネージドの Redis や Memcache のような Memorystore を使うことです。独自開発の代替ツールとして、Google のマネージド NoSQL ドキュメント ストアである Firestore があります。Firestore のマルチ リージョンのロケーションのいずれかを選択した場合、99.999% の月間稼働率のサービスレベル契約(SLA)が提供されます。
セッション アフィニティによる、同じインスタンスへの WebSocket の再接続
記事のまとめに移る前に、冒頭で取り上げたもう一つのユースケース、つまり WebSocket を使用するアプリケーションについて深く掘り下げてみたいと思います。
WebSocket の人気の秘密は、接続されたクライアントに直接メッセージを送信する方法をウェブサーバーに提供していることにあります。WebSocket は、クライアント(通常はブラウザ)とサーバーの間のエンドツーエンド接続で、メッセージ通知、共同編集ツール、マルチプレイヤー ゲームなどのユースケースを可能にします。Cloud Run はデフォルトで WebSocket に完全対応しており、構成は不要です。
接続が開かれてさえいれば、Cloud Run はコンテナ インスタンスを停止しません。詳しくは、コンテナ ランタイムの契約のドキュメントをご覧ください。WebSocket の使用中に、なぜセッション アフィニティのことまで心配しなければならないのかとお思いかもしれませんね。
現実には、アプリケーションのエラーなどのさまざまな理由で、接続が切れることがありえます。また、Cloud Run では、WebSocket のリクエストに対する最大時間が適用されることにも注意する必要があります。接続がタイムアウトより長く続いた場合、Cloud Run は接続を終了します。
WebSocket 接続が中断された場合でも、クライアントが同じインスタンスに再接続すれば、サーバーサイドの状態を再構築する必要がないため便利です。ゲームの構築中は、余計なラグの発生は回避したいですし、プレーヤーをスナップショットして永続化した最後の状態に復元したくもありませんよね。
セッション アフィニティを使用すると、リクエストと WebSocket の両方が、最初のリクエストを処理したインスタンスにルーティングされます。ただし、そのインスタンスがまだ存在し、キャパシティがあることが条件です。
WebSocket は通常の HTTP/1.1 リクエストとレスポンスとして開始される
セッション アフィニティはリクエストと WebSocket で同じように動作します。ブラウザが新しい WebSocket 接続を初期化した場合、その接続は、ここに示すように HTTP ベースの handshake から開始されるからです。
WebSocket を作成すると、次のことが発生します。
クライアントによって新しい TCP 接続が開かれる。
クライアントは、アップグレード ヘッダーを含む通常の HTTP/1.1 リクエストを送信し、HTTP から WebSocket へのプロトコル変更をサーバーに呼びかける。
サーバーはステータス コード 101 の HTTP レスポンス(プロトコル切り替え)を返す。
サーバーとクライアント両方が直ちに WebSocket プロトコルに切り替える。
WebSocket 接続を開始する HTTP リクエストとレスポンスの handshake には、セッション アフィニティ Cookie を含む HTTP ヘッダーを含めることができます。
WebSocket サーバーをプロトコル レベルで実装することはありませんので、ご安心ください。WebSocket サーバーの作成を支援するライブラリが、主要なプログラミング言語すべてで利用可能です。Socket.io は、Node.js で非常によく使われる例です。
まとめ
この投稿では、6 月からプレビュー公開している新機能のセッション アフィニティについてご紹介しました。セッション アフィニティの重要なポイントは以下です。
生成されたセッション アフィニティ Cookie を使用して、最初のリクエストを処理したのと同じインスタンスにクライアント リクエストをルーティングします。
これはローカル状態を再構築するコストを回避するためです。最適化の手段としてご使用ください。
あくまでもベスト エフォート型のサービスであり、効果が保証されるものではありません。リクエストが来たときにコンテナが利用できなかったり、キャパシティ不足だったりすることがありますが、その場合は、Cloud Run がアフィニティを別のコンテナに移動します。
サーバーサイドのセッション データなど、リクエスト間で持続する必要があり、簡単に再構築できないデータの保存には使用しないでください。
次のステップ
次のステップとして、以下のドキュメントをご活用ください。
Codelab の「Dev to Prod in Three Easy Steps with Cloud Run(Cloud Run: 3 ステップで簡単に開発から本番環境へ)」では、小さなウェブアプリを構築して、Cloud Run にデプロイする方法を紹介しています。
セッション アフィニティを有効にする方法について学びます。
セッション アフィニティはプレビュー版でリリースされており、Google では皆様のフィードバックをお待ちしています。ぜひご意見やご感想を私の Twitter アカウント(@wietsevenema)または LinkedIn (Wietse Venema)までお送りください。
- Google Cloud デベロッパー アドボケイト Wietse Venema