Cloud Run 用 WebSocket チャット サービス構築のチュートリアル


このチュートリアルでは、永続的な接続により双方向通信を行う WebSocket を使用して、マルチルームのリアルタイム チャット サービスを作成する方法について説明します。WebSocket を使用すると、サーバーに更新をポーリングすることなく、クライアントとサーバーの両方がメッセージを相互に push できます。

セッション アフィニティを使用するように Cloud Run を構成できますが、これはベスト エフォート型アフィニティになります。つまり、新しいリクエストが、別のインスタンスにルーティングされる可能性がまだあるということです。そのため、チャット サービスのユーザー メッセージは、1 つのインスタンスに接続されたクライアント間だけでなく、すべてのインスタンス間で同期する必要があります。

デザインの概要

このサンプル チャット サービスは、Memorystore for Redis インスタンスを使用して、ユーザー メッセージを保存し、すべてのインスタンス間で同期します。Redis は Pub/Sub メカニズムを使用して(Cloud Pub/Sub プロダクトではありません)、任意のインスタンスに接続された登録済みクライアントにデータを push し、HTTP で更新をポーリングしないようにします。

ただし、push 更新があっても、起動されたインスタンスはコンテナに push された新しいメッセージのみを受信します。以前のメッセージを読み込むには、メッセージ履歴を永続ストレージ ソリューションに保存して取得する必要があります。このサンプルでは、Redis の従来のオブジェクト ストアの機能を使用して、メッセージ履歴をキャッシュに保存して取得します。

アーキテクチャ図
この図は、各 Cloud Run インスタンスへの複数のクライアント接続を示しています。各インスタンスは、サーバーレス VPC アクセス コネクタ経由で Memorystore for Redis インスタンスに接続します。

Redis インスタンスは、アクセス制御されたプライベート IP を使用してインターネットから保護され、Redis インスタンスと同じバーチャル プライベート ネットワークで実行されているサービスに限定されます。したがって、Cloud Run サービスが Redis に接続するには、サーバーレス VPC アクセス コネクタが必要です。サーバーレス VPC アクセスの詳細をご確認ください。

制限事項

  • このチュートリアルでは、エンドユーザー認証やセッション キャッシュについては説明しません。エンドユーザー認証の詳細については、Cloud Run チュートリアルのエンドユーザー認証をご覧ください。

  • このチュートリアルでは、チャット メッセージの履歴を制限なく保存し、取得するために、Firestore などのデータベースを実装することはありません。

  • このサンプル サービスを本番環境で使用できるようにするには、追加の要素が必要です。レプリケーションと自動フェイルオーバーを使用した高可用性を実現するには、スタンダード ティアの Redis インスタンスをおすすめします。

目標

  • WebSocket を使用する Cloud Run サービスを作成、ビルド、デプロイする。

  • Memorystore for Redis インスタンスに接続して、インスタンス間で新しいメッセージを公開および登録する。

  • サーバーレス VPC アクセス コネクタを使用して、Cloud Run サービスを Memorystore に接続する。

費用

このドキュメントでは、Google Cloud の次の課金対象のコンポーネントを使用します。

料金計算ツールを使うと、予想使用量に基づいて費用の見積もりを生成できます。 新しい Google Cloud ユーザーは無料トライアルをご利用いただける場合があります。

始める前に

  1. Sign in to your Google Cloud account. If you're new to Google Cloud, create an account to evaluate how our products perform in real-world scenarios. New customers also get $300 in free credits to run, test, and deploy workloads.
  2. Google Cloud Console の [プロジェクト セレクタ] ページで、Google Cloud プロジェクトを選択または作成します。

    プロジェクト セレクタに移動

  3. Google Cloud プロジェクトで課金が有効になっていることを確認します

  4. Google Cloud Console の [プロジェクト セレクタ] ページで、Google Cloud プロジェクトを選択または作成します。

    プロジェクト セレクタに移動

  5. Google Cloud プロジェクトで課金が有効になっていることを確認します

  6. Cloud Run, Memorystore for Redis, Serverless VPC Access, Artifact Registry, and Cloud Build API を有効にします。

    API を有効にする

  7. gcloud CLI をインストールして初期化します

必要なロール

チュートリアルを完了するために必要な権限を取得するには、プロジェクトに対して次の IAM ロールを付与するよう管理者に依頼してください。

ロールの付与の詳細については、アクセスの管理をご覧ください。

必要な権限は、カスタムロールや他の事前定義ロールから取得することもできます。

gcloud のデフォルトを設定する

Cloud Run サービスを gcloud のデフォルトに構成するには:

  1. デフォルト プロジェクトを設定します。

    gcloud config set project PROJECT_ID

    PROJECT_ID は、このチュートリアルで作成したプロジェクトの名前に置き換えます。

  2. 選択したリージョン向けに gcloud を構成します。

    gcloud config set run/region REGION

    REGION は、任意のサポートされている Cloud Run のリージョンに置き換えます。

Cloud Run のロケーション

Cloud Run はリージョナルです。つまり、Cloud Run サービスを実行するインフラストラクチャは特定のリージョンに配置され、そのリージョン内のすべてのゾーンで冗長的に利用できるように Google によって管理されます。

レイテンシ、可用性、耐久性の要件を満たしていることが、Cloud Run サービスを実行するリージョンを選択する際の主な判断材料になります。一般的には、ユーザーに最も近いリージョンを選択できますが、Cloud Run サービスで使用されている他の Google Cloud サービスのロケーションも考慮する必要があります。使用する Google Cloud サービスが複数のロケーションにまたがっていると、サービスの料金だけでなくレイテンシにも影響することがあります。

Cloud Run は、次のリージョンで利用できます。

ティア 1 料金を適用

  • asia-east1(台湾)
  • asia-northeast1(東京)
  • asia-northeast2(大阪)
  • europe-north1(フィンランド) リーフアイコン 低 CO2
  • europe-southwest1(マドリッド)
  • europe-west1(ベルギー) リーフアイコン 低 CO2
  • europe-west4(オランダ)
  • europe-west8(ミラノ)
  • europe-west9(パリ) リーフアイコン 低 CO2
  • me-west1(テルアビブ)
  • us-central1(アイオワ) リーフアイコン 低 CO2
  • us-east1(サウスカロライナ)
  • us-east4(北バージニア)
  • us-east5(コロンバス)
  • us-south1(ダラス)
  • us-west1(オレゴン) リーフアイコン 低 CO2

ティア 2 料金を適用

  • africa-south1(ヨハネスブルグ)
  • asia-east2(香港)
  • asia-northeast3(ソウル、韓国)
  • asia-southeast1(シンガポール)
  • asia-southeast2 (ジャカルタ)
  • asia-south1(ムンバイ、インド)
  • asia-south2(デリー、インド)
  • australia-southeast1(シドニー)
  • australia-southeast2(メルボルン)
  • europe-central2(ワルシャワ、ポーランド)
  • europe-west10(ベルリン)
  • europe-west12(トリノ)
  • europe-west2(ロンドン、イギリス) リーフアイコン 低 CO2
  • europe-west3(フランクフルト、ドイツ) リーフアイコン 低 CO2
  • europe-west6(チューリッヒ、スイス) リーフアイコン 低 CO2
  • me-central1(ドーハ)
  • me-central2(ダンマーム)
  • northamerica-northeast1(モントリオール) リーフアイコン 低 CO2
  • northamerica-northeast2(トロント) リーフアイコン 低 CO2
  • southamerica-east1(サンパウロ、ブラジル) リーフアイコン 低 CO2
  • southamerica-west1(サンティアゴ、チリ) リーフアイコン 低 CO2
  • us-west2(ロサンゼルス)
  • us-west3(ソルトレイクシティ)
  • us-west4(ラスベガス)

Cloud Run サービスをすでに作成している場合は、Google Cloud コンソールの Cloud Run ダッシュボードにリージョンが表示されます。

コードサンプルを取得する

使用するコードサンプルを取得するには:

  1. ローカルマシンにサンプル リポジトリのクローンを作成します。

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

  2. Cloud Run のサンプルコードが含まれているディレクトリに移動します。

    Node.js

    cd nodejs-docs-samples/run/websockets/

コードについて

Socket.io は、ブラウザとサーバー間でのリアルタイム双方向通信を可能にするライブラリです。Socket.io は WebSocket の実装ではありませんが、複数の通信プロトコルに対応するシンプルな API を提供する機能を、信頼性の向上、自動再接続、すべてのクライアントまたは一部のクライアントへのブロードキャストなどの追加機能とラップします。

クライアントサイドの統合

<script src="/socket.io/socket.io.js"></script>

クライアントは、接続ごとに新しい Socket インスタンスをインスタンス化します。このサンプルはサーバーサイドでレンダリングされるため、サーバー URL を定義する必要はありません。ソケット インスタンスはイベントを出力しリッスンできます。

// Initialize Socket.io
const socket = io('', {
  transports: ['websocket'],
});
// Emit "sendMessage" event with message
socket.emit('sendMessage', msg, error => {
  if (error) {
    console.error(error);
  } else {
    // Clear message
    $('#msg').val('');
  }
});
// Listen for new messages
socket.on('message', msg => {
  log(msg.user, msg.text);
});

// Listen for notifications
socket.on('notification', msg => {
  log(msg.title, msg.description);
});

// Listen connect event
socket.on('connect', () => {
  console.log('connected');
});

サーバーサイドの統合

サーバーサイドで、Socket.io サーバーが初期化され、HTTP サーバーに接続されます。クライアントサイドと同様に、Socket.io サーバーがクライアントと接続すると、接続ごとにソケット インスタンスが作成され、メッセージの出力やリッスンに使用されます。Socket.io では、「チャットルーム」用の簡易インターフェース、または任意のチャンネルも用意されていて、ソケットが自由に参加または離脱できます。

// Initialize Socket.io
const server = require('http').Server(app);
const io = require('socket.io')(server);

const {createAdapter} = require('@socket.io/redis-adapter');
// Replace in-memory adapter with Redis
const subClient = redisClient.duplicate();
io.adapter(createAdapter(redisClient, subClient));
// Add error handlers
redisClient.on('error', err => {
  console.error(err.message);
});

subClient.on('error', err => {
  console.error(err.message);
});

// Listen for new connection
io.on('connection', socket => {
  // Add listener for "signin" event
  socket.on('signin', async ({user, room}, callback) => {
    try {
      // Record socket ID to user's name and chat room
      addUser(socket.id, user, room);
      // Call join to subscribe the socket to a given channel
      socket.join(room);
      // Emit notification event
      socket.in(room).emit('notification', {
        title: "Someone's here",
        description: `${user} just entered the room`,
      });
      // Retrieve room's message history or return null
      const messages = await getRoomFromCache(room);
      // Use the callback to respond with the room's message history
      // Callbacks are more commonly used for event listeners than promises
      callback(null, messages);
    } catch (err) {
      callback(err, null);
    }
  });

  // Add listener for "updateSocketId" event
  socket.on('updateSocketId', async ({user, room}) => {
    try {
      addUser(socket.id, user, room);
      socket.join(room);
    } catch (err) {
      console.error(err);
    }
  });

  // Add listener for "sendMessage" event
  socket.on('sendMessage', (message, callback) => {
    // Retrieve user's name and chat room  from socket ID
    const {user, room} = getUser(socket.id);
    if (room) {
      const msg = {user, text: message};
      // Push message to clients in chat room
      io.in(room).emit('message', msg);
      addMessageToCache(room, msg);
      callback();
    } else {
      callback('User session not found.');
    }
  });

  // Add listener for disconnection
  socket.on('disconnect', () => {
    // Remove socket ID from list
    const {user, room} = deleteUser(socket.id);
    if (user) {
      io.in(room).emit('notification', {
        title: 'Someone just left',
        description: `${user} just left the room`,
      });
    }
  });
});

Socket.io には、ソケットを提供しているサーバーに関係なく、すべてのクライアントにイベントをブロードキャストする Redis アダプタも用意されています。Socket.io では、Redis の Pub/Sub メカニズムを使用するだけで、データは保存しません。

const {createAdapter} = require('@socket.io/redis-adapter');
// Replace in-memory adapter with Redis
const subClient = redisClient.duplicate();
io.adapter(createAdapter(redisClient, subClient));

Socket.io の Redis アダプタは、チャットルームのメッセージ履歴の保存に使用された Redis クライアントを再利用できます。各コンテナは Redis インスタンスへの接続を作成します。Cloud Run は多数のインスタンスを作成できます。これは、Redis がサポートする 65,000 接続をかなり下回っています。この量のトラフィックをサポートする必要がある場合は、サーバーレス VPC アクセス コネクタのスループットも評価する必要があります。

再接続

Cloud Run のタイムアウトは最大 60 分です。そのため、タイムアウトが発生する可能性に備えて、再接続ロジックを追加する必要があります。場合によっては、Socket.io が切断または接続エラーイベントの後に自動的に再接続を試みます。クライアントが同じインスタンスに再接続する保証はありません。

// Listen for reconnect event
socket.io.on('reconnect', () => {
  console.log('reconnected');
  // Emit "updateSocketId" event to update the recorded socket ID with user and room
  socket.emit('updateSocketId', {user, room}, error => {
    if (error) {
      console.error(error);
    }
  });
});
// Add listener for "updateSocketId" event
socket.on('updateSocketId', async ({user, room}) => {
  try {
    addUser(socket.id, user, room);
    socket.join(room);
  } catch (err) {
    console.error(err);
  }
});

すべてのリクエストが閉じるかタイムアウトするまで、アクティブな接続がある間はインスタンスが保持されます。Cloud Run のセッション アフィニティを使用している場合でも、新しいリクエストはアクティブなコンテナにロードバランスされるため、コンテナはスケールインできます。トラフィックの急増後に多数のコンテナが保持されることが懸念される場合は、未使用のソケットが頻繁にクリーンアップされるように最大タイムアウト値を下げることができます。

サービスの配布

  1. Memorystore for Redis インスタンスを作成します。

    gcloud redis instances create INSTANCE_ID --size=1 --region=REGION

    INSTANCE_ID はインスタンスの名前(例: my-redis-instance)に置き換え、REGION_ID はすべてのリソースとサービスのリージョン(例: us-central1)に置き換えます。

    インスタンスに、デフォルトのサービス ネットワーク範囲から IP 範囲が自動的に割り振られます。このチュートリアルでは、Redis インスタンスのメッセージのローカル キャッシュに 1 GB のメモリを使用します。詳細については、ユースケース用の Memorystore インスタンスの初期サイズの決定をご覧ください。

  2. サーバーレス VPC アクセス コネクタを設定します。

    Redis インスタンスに接続するには、Cloud Run サービスが Redis インスタンスの承認済み VPC ネットワークにアクセスする必要があります。

    VPC コネクタごとに、コネクタ インスタンスを配置する独自の /28 サブネットが必要です。この IP 範囲は、VPC ネットワーク内の既存の IP アドレス予約と重複してはいけません。たとえば、10.8.0.0/28)はほとんどの新規プロジェクトで機能しますが、10.9.0.0/28)など、別の未使用のカスタム IP 範囲を指定することもできます。現在予約されている IP 範囲は、Google Cloud コンソールで確認できます。

    gcloud compute networks vpc-access connectors create CONNECTOR_NAME \
      --region REGION \
      --range "10.8.0.0/28"
    

    CONNECTOR_NAME は、アプリケーションの名前に置き換えます。

    このコマンドは、Redis インスタンスと同じデフォルト VPC ネットワークに、e2-micro マシンサイズのコネクタを作成します。コネクタのマシンサイズを大きくすると、コネクタのスループットを向上させることができますが、コストも増加します。また、コネクタは Redis インスタンスと同じリージョンに配置する必要があります。サーバーレス VPC アクセスの構成の詳細をご確認ください。

  3. Redis インスタンスの承認済みネットワークの IP アドレスを使用して、環境変数を定義します。

     export REDISHOST=$(gcloud redis instances describe INSTANCE_ID --region REGION --format "value(host)")
  4. サービス ID として機能するサービス アカウントを作成します。このアカウントには、デフォルトでは、プロジェクト メンバーシップ以外の権限は付与されません。

    gcloud iam service-accounts create chat-identity
    gcloud projects add-iam-policy-binding PROJECT_ID \
    --member=serviceAccount:chat-identity@PROJECT_ID.iam.gserviceaccount.com \
    --role=roles/serviceusage.serviceUsageConsumer
    
  5. コンテナ イメージをビルドして Cloud Run にデプロイします。

    gcloud run deploy chat-app --source . \
        --vpc-connector CONNECTOR_NAME \
        --allow-unauthenticated \
        --timeout 3600 \
        --service-account chat-identity \
        --update-env-vars REDISHOST=$REDISHOST

    プロンプトが表示されたら、y と入力して必要な API をインストールするように応答します。これが必要なのはプロジェクトに対して 1 回だけです。設定ページに記載されているように、デフォルト値を設定しない場合は、別のプロンプトにプラットフォームとリージョンを指定して応答します。ソースコードからのデプロイで詳細をご確認ください。

試してみる

完成したサービスを試すには:

  1. ブラウザで、前述のデプロイの手順により提供された URL に移動します。

  2. 自分の名前とチャットルームを追加してログインします。

  3. チャットルームにメッセージを送信します。

これらのサービスの開発を継続する場合、Google Cloud の他のサービスへの Identity and Access Management(IAM)アクセスが制限されます。他の多くのサービスにアクセスするには、追加の IAM ロールをこれらのサービスに与える必要があることにご注意ください。

クリーンアップ

このチュートリアル用に新規プロジェクトを作成した場合は、そのプロジェクトを削除します。既存のプロジェクトを使用し、このチュートリアルで変更を加えずに残す場合は、チュートリアル用に作成したリソースを削除します。

プロジェクトを削除する

課金をなくす最も簡単な方法は、チュートリアル用に作成したプロジェクトを削除することです。

プロジェクトを削除するには:

  1. Google Cloud コンソールで、[リソースの管理] ページに移動します。

    [リソースの管理] に移動

  2. プロジェクト リストで、削除するプロジェクトを選択し、[削除] をクリックします。
  3. ダイアログでプロジェクト ID を入力し、[シャットダウン] をクリックしてプロジェクトを削除します。

チュートリアル リソースを削除する

  1. このチュートリアルでデプロイした Cloud Run サービスを削除します。

    gcloud run services delete SERVICE-NAME

    SERVICE-NAME は、選択したサービス名です。

    Cloud Run サービスは Google Cloud コンソールから削除することもできます。

  2. チュートリアルの設定時に追加した gcloud のデフォルト リージョン構成を削除します。

     gcloud config unset run/region
    
  3. プロジェクト構成を削除します。

     gcloud config unset project
    
  4. このチュートリアルで作成した他の Google Cloud リソースを削除します。

次のステップ