教程:为 Cloud Run 构建 WebSocket 聊天服务

本教程介绍如何使用 WebSocket 创建具有多个聊天室的实时聊天服务,并通过持久性连接进行双向通信。通过 WebSocket,客户端和服务器可以互相推送消息,而无需轮询服务器获取更新。

虽然 Cloud Run 可以自动扩缩容器实例数量来传送所有流量,但 Cloud Run 不提供会话亲和性。这意味着,任何新请求都可能路由到其他容器实例。因此,聊天服务中的用户消息需要在所有容器实例之间同步,而不仅仅是在连接到一个容器实例的客户端之间同步。

设计概览

此示例聊天服务使用 Memorystore for Redis 实例在所有容器实例中存储和同步用户消息。Redis 使用发布/订阅机制(不要与产品 Cloud Pub/Sub 混淆)将数据推送到连接到任何容器实例的订阅客户端,从而无需进行 HTTP 轮询来获取更新。

但是,即使有推送更新,任何启动的容器实例也只会收到推送到容器的新消息。如需加载之前的消息,需要通过永久性存储解决方案存储和检索消息历史记录。此示例使用 Redis 常规的对象存储功能来缓存和检索消息历史记录。

架构图
该图显示了每个 Cloud Run 容器实例的多个客户端连接。每个容器实例都通过无服务器 VPC 访问通道连接器连接到 Memorystore for Redis 实例。

Redis 实例受具有访问权限控制的专用 IP 保护以免受互联网攻击,并且限制为 Redis 实例所在虚拟专用网中运行的服务;因此,Cloud Run 服务需要无服务器 VPC 访问通道连接器才能连接到 Redis。详细了解无服务器 VPC 访问通道

限制

  • 本教程未介绍最终用户身份验证或会话缓存。如需详细了解最终用户身份验证,请参阅有关最终用户身份验证的 Cloud Run 教程。

  • 本教程未实现数据库(例如 Firestore)以无限期地存储和检索聊天记录。

  • 此示例服务还需要其他元素才能用于生产环境。建议使用标准层级 Redis 实例,通过复制和自动故障切换提供高可用性

目标

  • 编写、构建和部署使用 WebSocket 的 Cloud Run 服务。

  • 连接到 Memorystore for Redis 实例,以跨容器实例发布和订阅新消息。

  • 使用无服务器 VPC 访问通道连接器连接 Cloud Run 服务和 Memorystore。

费用

本教程使用 Google Cloud 的以下收费组件:

请使用价格计算器根据您的预计用量来估算费用。

您应该能够完全使用免费试用赠金完成此项目。

Cloud Platform 新用户可能有资格申请免费试用

准备工作

  1. 登录您的 Google Cloud 帐号。如果您是 Google Cloud 新手,请创建一个帐号来评估我们的产品在实际场景中的表现。新客户还可获享 $300 赠金,用于运行、测试和部署工作负载。
  2. 在 Google Cloud Console 的项目选择器页面上,选择或创建一个 Google Cloud 项目。

    转到“项目选择器”

  3. 确保您的 Cloud 项目已启用结算功能。 了解如何确认您的项目是否已启用结算功能

  4. 启用 Cloud Run, Memorystore for Redis, Serverless VPC Access, Artifact Registry, and Cloud Build API。

    启用 API

  5. 安装并初始化 Cloud SDK。

设置 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 价格

基于层级 2 价格

  • asia-east2(香港)
  • asia-northeast3(韩国首尔)
  • asia-southeast1(新加坡)
  • asia-southeast2 (雅加达)
  • asia-south1(印度孟买)
  • asia-south2(印度德里)
  • australia-southeast1(悉尼)
  • australia-southeast2(墨尔本)
  • europe-central2(波兰,华沙)
  • europe-west2(英国伦敦)
  • europe-west3(德国法兰克福)
  • europe-west6(瑞士苏黎世) 叶形图标 二氧化碳排放量低
  • northamerica-northeast1(蒙特利尔) 叶形图标 二氧化碳排放量低
  • northamerica-northeast2(多伦多)
  • southamerica-east1(巴西圣保罗) 叶形图标 二氧化碳排放量低
  • us-west2(洛杉矶)
  • us-west3(盐湖城)
  • us-west4(拉斯维加斯)

如果您已创建 Cloud Run 服务,则可以在 Cloud Console 的 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>

客户端为每个连接实例化一个新的套接字实例。由于此示例是服务器端呈现的,因此无需定义服务器网址。套接字实例可以发出和监听事件。

// 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 redisAdapter = require('@socket.io/redis-adapter');
// Replace in-memory adapter with Redis
io.adapter(redisAdapter(redisClient, redisClient.duplicate()));

// 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 的发布/订阅机制,不存储任何数据。

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

Socket.io 的 Redis 适配器可以重复使用用于存储聊天室的消息记录的 Redis 客户端。每个容器都会创建一个与 Redis 实例的连接,而 Cloud Run 最多可以有 1000 个实例。这远低于 Redis 可以支持的 65000 个连接。如果您需要支持这么大的流量,还需要评估无服务器 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);
  }
});

如果存在活动连接,容器实例将一直存在,直到所有请求关闭或超时为止。由于没有会话亲和性,新请求将被负载均衡到活动容器,这允许容器缩容。如果您担心大量容器在流量高峰后继续存在,可以降低超时值上限,从而更频繁地清理未使用的套接字。

发布服务

  1. 创建一个 Memorystore for Redis 实例:

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

    INSTANCE_ID 替换为实例名称(即 my-redis-instance),并将 REGION_ID 替换为所有资源和服务(例如 us-central1)的区域

    系统会自动为实例分配一个在默认服务网络范围内的 IP 地址范围。本教程使用 1GB 的内存用于本地缓存 Redis 实例中的消息。详细了解如何为您的使用场景确定 Memorystore 实例的初始大小

  2. 设置无服务器 VPC 访问通道连接器:

    要连接到 Redis 实例,您的 Cloud Run 服务需要访问 Redis 实例的已获授权的 VPC 网络。

    每个 VPC 连接器都需要有自己的 /28 子网以放置连接器实例。此 IP 范围不得与 VPC 网络中预留的任何现有 IP 地址重叠。例如,10.8.0.0 (/28) 适用于大多数新项目,您也可以指定另一个未使用的自定义 IP 范围,例如 10.9.0.0 (/28)。您可以在 Cloud Console 中查看当前预留的 IP 地址范围。

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

    CONNECTOR_NAME 替换为您的连接器的名称。

    此命令会在默认 VPC 网络中创建一个连接器,该连接器与 Redis 实例相同,机器大小为 e2-micro。增加连接器的机器大小可以提高连接器的吞吐量,但也会增加费用。连接器还必须与 Redis 实例位于同一区域。详细了解如何配置无服务器 VPC 访问通道

  3. 使用 Redis 实例的授权网络的 IP 地址定义环境变量:

     export REDISHOST=$(gcloud redis instances describe INSTANCE_ID --region REGION --format "value(host)")
  4. 创建一个服务帐号作为服务身份。默认情况下,该帐号不具备除项目成员资格之外的任何特权。

    gcloud iam service-accounts create chat-identity

    此服务不需要与 Google Cloud 中的任何其他服务交互;因此,无需向此服务帐号分配任何额外权限。

  5. 构建容器映像并部署到 Cloud Run:

    gcloud beta 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. 在浏览器中导航至上述部署步骤提供的网址。

  2. 添加你的姓名和聊天室以登录。

  3. 向聊天室发送消息!

如果您选择继续开发这些服务,请注意,它们已限制了 Identity and Access Management (IAM) 对 Google Cloud 其余服务的访问权限,并需要额外的 IAM 角色才能访问众多其他服务。

费用讨论

以下是一个聊天服务的费用明细示例,该聊天服务托管在爱荷华 (us-central1) 并且具有 5 GB Redis 实例和无服务器 VPC 访问通道连接器。

产品 每月费用
Redis 费用 = 预配容量 (5 GB) * 区域层级价格 (us-central1)

标准层级:5 GB * $0.054 GB/小时 * 730 小时/月 = $197
基本层级:5 GB * $0.027 GB/小时 * 730 小时/月 = $99
无服务器 VPC 访问通道 费用 = 机器大小价格 * 实例数(最小实例数默认为 2)

f1-micro:$3.88 * 2 个实例 = $7.76
e2- micro:$6.11 * 2 个实例 = $12.22
e2-standard-4:$97.83 * 2 个实例 = $195.66
Cloud Run 费用 = Cloud Run 服务资源使用时间 * 价格 * 实例数

内存:0.5 GiB * $0.00000250 / GiB-s * 60 秒/分钟 * 60 分钟/小时 * 8 小时 * 30.5 天 * 4 个实例 = $4.39†
CPU:1 个 vCPU * $0.00002400 / vCPU-s * 60 秒/分钟 * 60 分钟/小时 * 8 小时 * 30.5 天 * 4 个实例 = $84.33†
请求:$0.40 / 百万 ~= $0
网络:$0.085/GB/月 ~= $0
总计 $197 + $12.22 + $89 ~= 每月 $298

† 请参阅下方的讨论,了解场景上下文。

本教程使用独立的基本层级 Redis 实例。服务层级可升级到标准层级,以获得自动启用的跨可用区复制和自动故障切换功能,从而实现高可用性。区域和容量也会影响 Redis 价格。例如,爱荷华 (us-central1) 的一个 5 GB 标准层级实例费用为每小时每 GB $0.054。每小时费用为 5 * $0.054,约为每小时 $0.27 或每月 $197。确定 Memorystore 实例的初始大小,并详细了解 Redis 价格

无服务器 VPC 访问通道连接器按实例大小和数量以及网络出站流量计费。增加大小和数量可以提高吞吐量或缩短消息的延迟时间。有 3 种大小的机器,f1-micro、e2-micro 和 e2-standard-4。最小实例数为 2,因此最少费用是机器大小的两倍。

Cloud Run 按内存、CPU、请求数量和网络的资源用量计费,舍入至最接近的 100 毫秒。本教程使用的默认 Cloud Run 设置为 512 MiB 和 1 个 vCPU,费用分别为 0.5 GiB * $0.00000250 / GiB 秒和 1 个 vCPU * $0.00002400 / vCPU 秒,即每个实例每小时总计 $0.091。并发对总费用有很大影响,但限制和建议因语言而异。增加并发可以减少所需的容器实例数量。Cloud Run 最多支持 250 个并发请求。在使用最多 1000 个容器实例和 250 个并发请求的情况下,您最多可以服务 25 万个客户端。例如,一个服务为 1000 名员工提供服务,并发请求数为 250,则至少需要 4 个实例。4 个实例在 1 个月(每天 8 小时)内的费用为 $0.091 * 4 个实例 * 8 小时 * 30.5 天 = $89。请求数量和网络将产生额外费用,但这部分费用可能很少。

对于托管在爱荷华 (us-central1) 的这一聊天服务,5 GB 标准 Redis 实例费用为 $197,默认的无服务器 VPC 访问通道连接器费用为 $12.22,Cloud Run 服务的预估费用为 89 美元,每月总费用为 298 美元。使用 Google Cloud 价格计算器查看预估费用。

清除数据

如果您为本教程创建了一个新项目,请删除项目。 如果您使用的是现有项目,希望保留此项目且不保留本教程中添加的任何更改,请删除为教程创建的资源

删除项目

为了避免产生费用,最简单的方法是删除您为本教程创建的项目。

如需删除项目,请执行以下操作:

  1. 在 Cloud Console 中,转到管理资源页面。

    转到“管理资源”

  2. 在项目列表中,选择要删除的项目,然后点击删除
  3. 在对话框中输入项目 ID,然后点击关闭以删除项目。

删除教程资源

  1. 删除您在本教程中部署的 Cloud Run 服务:

    gcloud run services delete SERVICE-NAME

    其中,SERVICE-NAME 是您选择的服务名称。

    您还可以从 Google Cloud Console 中删除 Cloud Run 服务。

  2. 移除您在教程设置过程中添加的 gcloud 默认区域配置:

     gcloud config unset run/region
    
  3. 移除项目配置:

     gcloud config unset project
    
  4. 删除在本教程中创建的其他 Google Cloud 资源:

后续步骤