首页 > 解决方案 > 遇到 540 秒超时限制后如何从 Firebase Functions 迁移到 Cloud Run?

问题描述

我正在阅读这个 Reddit 线程,其中一位用户提到 540 秒是 Firebase 函数的限制,建议迁移到 Cloud Run。

正如其他人所说,540 秒是最大超时时间,如果您想在不更改代码的其他内容的情况下增加它,请考虑迁移到 Cloud Run。​- Reddit上的@samtstern

在查看了 YouTube 和 Google 上的Node.JS 快速入门文档 和其他内容后,我没有找到一个很好的指南来解释如何将您的 Firebase 函数移动到 Cloud Run。

我阅读的内容未解决的问题之一,例如:我应该用什么替换firebase-functions包来定义函数?ETC...

那么,我怎样才能将我的 Firebase 功能转移到 Cloud Run 以不遇到 540 秒最大超时限制?

​const functions = require('firebase-functions');
​const runtimeOpts = {timeoutSeconds: 540,memory: '2GB'}
​exports.hourlyData = functions.runWith(runtimeOpts).pubsub.schedule('every 1 hours')

标签: javascriptnode.jsfirebasegoogle-cloud-functionsgoogle-cloud-run

解决方案


前言:以下步骤已针对更广泛的受众进行了推广,而不仅仅是 OP 的问题(涵盖 HTTP 事件、计划和 Pub/Sub 函数),并改编自问题中链接的文档:在 Cloud Run 上部署 Node.JS 图像

第 0 步:代码/架构审查

通常情况下,超过 Cloud Function 的 9 分钟超时是由于代码中的错误导致的 - 请务必在切换到 Cloud Run 之前对此进行评估,因为这只会使问题变得更糟。其中最常见的是顺序异步处理而不是并行异步处理(通常由awaitfor/while循环中使用引起)。

如果您的代码正在执行需要很长时间的有意义的工作,请考虑将其分片到可以并行处理输入数据的“子函数”。您可以使用单个函数来触发一个函数的多个实例,每个实例负责处理不同的用户 ID 范围,例如a-l\uf8ffm-z\uf8ff、和A-L\uf8ff,而不是为数据库中的每个用户处理数据。M-Z\uf8ff0-9\uf8ff

最后,Cloud Run 和 Cloud Functions 非常相似,它们旨在接受请求,处理它,然后返回响应。Cloud Functions 的限制时间最长为 9 分钟,Cloud Runs 的限制时间最长为 60 分钟。一旦该响应完成(因为服务器结束响应、客户端丢失连接或客户端中止请求),实例将被严重限制或终止。虽然您可以在使用 Cloud Run 时使用 WebSockets 和 gRPC 在服务器和客户端之间进行持久通信,但它们仍然受到此限制。有关更多信息,请参阅Cloud Run:一般开发技巧文档。

与其他无服务器解决方案一样,您的客户端和服务器需要能够处理与不同实例的连接。您的代码不应使用本地状态(如会话数据的本地存储)。有关详细信息,请参阅设置请求超时文档。

第 1 步:安装 Google Cloud SDK

我将向您介绍此步骤的安装 Google Cloud SDK 文档

安装后,调用gcloud auth login并使用用于目标 Firebase 项目的帐户登录。

第 2 步:获取您的 Firebase 项目设置

在 Firebase 控制台中打开您的项目设置,并记下您的项目 ID默认 GCP 资源位置

Firebase Functions 和 Cloud Run 实例应尽可能与您的 GCP 资源位于同一位置。在 Firebase Functions 中,这是通过更改代码中的区域并使用 CLI 进行部署来实现的。对于 Cloud Run,您可以在命令行上将这些参数指定为标志(或使用 Google Cloud Console)。对于以下说明和简单起见,我将使用us-central1我的默认 GCP 资源位置nam5 (us-central).

如果在您的项目中使用 Firebase 实时数据库,请访问Firebase 控制台中的 RTDB 设置并记下您的数据库 URL。这通常是形式https://PROJECT_ID.firebaseio.com/

如果在您的项目中使用 Firebase 存储,请访问Firebase 控制台中的 Cloud Storage 设置并记下您的Bucket URI。从这个 URI 中,我们需要注意主机(忽略gs://部分),它通常是PROJECT_ID.appspot.com.

您可以复制以下表格以帮助跟踪:

项目编号: PROJECT_ID
数据库网址: https://PROJECT_ID.firebaseio.com
存储桶: PROJECT_ID.appspot.com
默认 GCP 资源位置:
选择的云运行区域

第 3 步:创建目录

在您的 Firebase 项目目录或您选择的目录中,创建一个新cloudrun文件夹。

与可以在单个代码模块中定义多个函数的 Firebase Cloud Functions 不同,每个 Cloud Run 映像都使用自己的代码模块。因此,每个 Cloud Run 映像都应存储在自己的目录中。

由于我们要定义一个名为 的 Cloud Run 实例helloworld,我们将创建一个名为helloworldinside的目录cloudrun

mkdir cloudrun
mkdir cloudrun/helloworld
cd cloudrun/helloworld

第 4 步:创建package.json

为了正确部署 Cloud Run 映像,我们需要提供一个package.json用于在已部署容器中安装依赖项的组件。

package.json文件的格式类似于:

{
  "name": "SERVICE_NAME",
  "description": "",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
    "image": "gcloud builds submit --tag gcr.io/PROJECT_ID/SERVICE_NAME --project PROJECT_ID",
    "deploy:public": "gcloud run deploy SERVICE_NAME --image gcr.io/PROJECT_ID/SERVICE_NAME --allow-unauthenticated --region REGION_ID --project PROJECT_ID",
    "deploy:private": "gcloud run deploy SERVICE_NAME --image gcr.io/PROJECT_ID/SERVICE_NAME --no-allow-unauthenticated --region REGION_ID --project PROJECT_ID",
    "describe": "gcloud run services describe SERVICE_NAME --region REGION_ID --project PROJECT_ID --platform managed",
    "find": "gcloud run services describe SERVICE_NAME --region REGION_ID --project PROJECT_ID --platform managed --format='value(status.url)'"
  },
  "engines": {
    "node": ">= 12.0.0"
  },
  "author": "You",
  "license": "Apache-2.0",
  "dependencies": {
    "express": "^4.17.1",
    "body-parser": "^1.19.0",
    /* ... */
  },
  "devDependencies": {
    /* ... */
  }
}

在上述文件中SERVICE_NAMEREGION_IDPROJECT_ID将根据步骤 2 中的详细信息进行适当替换。我们还安装expressbody-parser处理传入的请求。

还有一些模块脚本可以帮助部署。

脚本名称 描述
image 将映像提交到 Cloud Build 以添加到 Container Registry 以执行其他命令。
deploy:public 部署来自上述命令的映像以供 Cloud Run 使用(同时允许任何请求者调用它)并返回其服务 URL(部分随机化)。
deploy:private 部署上述命令中的映像以供 Cloud Run 使用(同时要求调用它的请求者是授权用户/服务帐户)并返回其服务 URL(部分随机)。
describe 获取已部署 Cloud Run 的统计信息和配置。
find 从响应中仅提取服务 URLnpm run describe

注意:这里的“授权用户”是指与项目关联的 Google 帐户,而不是普通的 Firebase 用户。要允许 Firebase 用户调用您的 Cloud Run,您必须使用deploy:publicCloud Run 代码中的令牌验证和处理令牌验证来部署它,从而适当地拒绝请求。

作为填写此文件的示例,您将获得以下信息:

{
  "name": "helloworld",
  "description": "Simple hello world sample in Node with Firebase",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
    "image": "gcloud builds submit --tag gcr.io/com-example-cloudrun/helloworld --project com-example-cloudrun",
    "deploy:public": "gcloud run deploy helloworld --image gcr.io/com-example-cloudrun/helloworld --allow-unauthenticated --region us-central1 --project com-example-cloudrun",
    "deploy:public": "gcloud run deploy helloworld --image gcr.io/com-example-cloudrun/helloworld --no-allow-unauthenticated --region us-central1 --project com-example-cloudrun",
    "describe": "gcloud run services describe helloworld --region us-central1 --project com-example-cloudrun --platform managed",
    "find": "gcloud run services describe helloworld --region us-central1 --project com-example-cloudrun --platform managed --format='value(status.url)'"
  },
  "engines": {
    "node": ">= 12.0.0"
  },
  "author": "You",
  "license": "Apache-2.0",
  "dependencies": {
    /* ... */
  },
  "devDependencies": {
    /* ... */
  }
}

第 5 步:创建容器文件

要告诉 Cloud Build 为您的 Cloud Run 映像使用哪个容器,您必须Dockerfile为您的映像创建一个容器。为防止将错误的文件发送到服务器,您还应该指定一个.dockerignore文件。

在此文件中,我们使用第 2 步中的 Firebase 项目设置来重新创建process.env.FIREBASE_CONFIG环境变量。此变量由 Firebase Admin SDK 使用,并包含以下 JSON 字符串形式的信息:

{
  databaseURL: "https://PROJECT_ID.firebaseio.com",
  storageBucket: "PROJECT_ID.appspot.com",
  projectId: "PROJECT_ID"
}

这里是cloudrun/helloworld/Dockerfile

# Use the official lightweight Node.js 14 image.
# https://hub.docker.com/_/node
FROM node:14-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./

# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production

# Copy local code to the container image.
COPY . ./

# Define default configuration for Admin SDK
# databaseURL is usually "https://PROJECT_ID.firebaseio.com", but may be different.
# TODO: Update me
ENV FIREBASE_CONFIG={"databaseURL":"https://PROJECT_ID.firebaseio.com","storageBucket":"PROJECT_ID.appspot.com","projectId":"PROJECT_ID"}

# Run the web service on container startup.
CMD [ "node", "index.js" ]

这里是cloudrun/helloworld/.dockerignore

Dockerfile
.dockerignore
node_modules
npm-debug.log

第 6 步:创建和部署您的入口点

启动新的 Cloud Run 实例时,它通常会使用PORT环境变量指定它希望您的代码监听的端口。

变体:迁移 HTTP 事件函数

当您使用包中的HTTP 事件函数时,它会在内部代表您firebase-functions处理正文解析。Functions Framework为此使用包并在此处定义解析器body-parser

要处理用户授权,您可以使用此validateFirebaseIdToken()中间件来检查随请求提供的 ID 令牌。

对于基于 HTTP 的 Cloud Run,需要配置 CORS 才能从浏览器调用它。这可以通过安装cors软件包并适当配置来完成。在下面的示例中,cors将反映发送给它的来源。

const express = require('express');
const cors = require('cors')({origin: true});
const app = express();

app.use(cors);

// To replicate a Cloud Function's body parsing, refer to
// https://github.com/GoogleCloudPlatform/functions-framework-nodejs/blob/d894b490dda7c5fd4690cac884fd9e41a08b6668/src/server.ts#L47-L95
app.use(/* body parsers */);

app.enable('trust proxy'); // To respect X-Forwarded-For header. (Cloud Run is behind a load balancer proxy)
app.disable('x-powered-by'); // Disables the 'x-powered-by' header added by express (best practice)

// Start of your handlers

app.get('/', (req, res) => {
  const name = process.env.NAME || 'World';
  res.send(`Hello ${name}!`);
});

// End of your handlers

const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`helloworld: listening on port ${port}`);
});

在该$FIREBASE_PROJECT_DIR/cloudrun/helloworld目录中,执行以下命令来部署您的映像:

npm run image           // builds container & stores to container repository
npm run deploy:public   // deploys container image to Cloud Run

变体:使用 Cloud Scheduler 调用

使用 Cloud Scheduler 调用 Cloud Run 时,您可以选择使用哪种方法来调用它(GET, POST(默认值), PUT, HEAD, DELETE)。要复制云函数datacontext参数,最好使用POST它们,因为这些将在请求正文中传递。与 Firebase 函数一样,来自 Cloud Scheduler 的这些请求可能会被重试,因此请确保正确处理幂等性。

注意:即使 Cloud Scheduler 调用请求的主体是 JSON 格式的,该请求也会使用Content-Type: text/plain,我们需要处理。

此代码改编自Functions Framework 源代码(Google LLC、Apache 2.0)

const express = require('express');
const { json } = require('body-parser');

async function handler(data, context) {
  /* your logic here */
  const name = process.env.NAME || 'World';
  console.log(`Hello ${name}!`);
}

const app = express();

// Cloud Scheduler requests contain JSON using 
"Content-Type: text/plain"
app.use(json({ type: '*/*' }));

app.enable('trust proxy'); // To respect X-Forwarded-For header. (Cloud Run is behind a load balancer proxy)
app.disable('x-powered-by'); // Disables the 'x-powered-by' header added by express (best practice)

app.post('/*', (req, res) => {
  const event = req.body;
  let data = event.data;
  let context = event.context;

  if (context === undefined) {
    // Support legacy events and CloudEvents in structured content mode, with
    // context properties represented as event top-level properties.
    // Context is everything but data.
    context = event;
    // Clear the property before removing field so the data object
    // is not deleted.
    context.data = undefined;
    delete context.data;
  }

  Promise.resolve()
    .then(() => handler(data, context))
    .then(
      () => {
        // finished without error
        // the return value of `handler` is ignored because
        // this isn't a callable function
        res.sendStatus(204); // No content
      },
      (err) => {
        // handler threw error
        console.error(err.stack);
        res.set('X-Google-Status', 'error');
        // Send back the error's message (as calls to this endpoint
        // are authenticated project users/service accounts)
        res.send(err.message);
      }
    )
});

const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`helloworld: listening on port ${port}`);
});

注意: Functions Framework 通过发回HTTP 200 OK带有标头的响应来处理错误X-Google-Status: error。这实际上意味着“成功失败”。作为一个局外人,我不确定为什么要这样做,但我可以假设它是为了让调用者知道不必费心重试该函数——它只会得到相同的结果。

在该$FIREBASE_PROJECT_DIR/cloudrun/helloworld目录中,执行以下命令来部署您的映像:

npm run image           // builds container & stores to container repository
npm run deploy:private  // deploys container image to Cloud Run

注意:在以下设置命令(只需运行一次)中,PROJECT_ID需要根据需要替换SERVICE_NAMESERVICE_URL和。IAM_ACCOUNT

接下来,我们需要创建一个Cloud Scheduler 可以用来调用 Cloud Run 的服务帐户。您可以随意调用它,例如scheduled-run-invoker. 此服务帐户的电子邮件将IAM_ACCOUNT在下一步中引用。这段Google Cloud Tech YouTube 视频(从正确的位置开始,大约 15 秒)将快速显示您需要做什么。创建帐户后,您可以在接下来的 30 秒左右的视频后创建 Cloud Scheduler 作业,或使用以下命令:

gcloud scheduler jobs create http scheduled-run-SERVICE_NAME /
  --schedule="every 1 hours" /
  --uri SERVICE_URL /
  --attempt-deadline 60m /
  --http-method post /
  --message-body='{"optional-custom-data":"here","if-you":"want"}' /
  --oidc-service-account-email IAM_ACCOUNT
  --project PROJECT_ID

您的 Cloud Run 现在应该已安排好。

变体:使用 Pub/Sub 调用

据我了解,部署过程与计划运行 ( deploy:private) 相同,但我不确定具体细节。不过,这里是 Pub/Sub 解析器的 Cloud Run 源代码:

此代码改编自Functions Framework 源代码(Google LLC、Apache 2.0)

const express = require('express');
const { json } = require('body-parser');

const PUBSUB_EVENT_TYPE = 'google.pubsub.topic.publish';
const PUBSUB_MESSAGE_TYPE =
  'type.googleapis.com/google.pubsub.v1.PubsubMessage';
const PUBSUB_SERVICE = 'pubsub.googleapis.com';

/**
 * Extract the Pub/Sub topic name from the HTTP request path.
 * @param path the URL path of the http request
 * @returns the Pub/Sub topic name if the path matches the expected format,
 * null otherwise
 */
const extractPubSubTopic = (path: string): string | null => {
  const parsedTopic = path.match(/projects\/[^/?]+\/topics\/[^/?]+/);
  if (parsedTopic) {
    return parsedTopic[0];
  }
  console.warn('Failed to extract the topic name from the URL path.');
  console.warn(
    "Configure your subscription's push endpoint to use the following path: ",
    'projects/PROJECT_NAME/topics/TOPIC_NAME'
  );
  return null;
};

async function handler(message, context) {
  /* your logic here */
  const name = message.json.name || message.json || 'World';
  console.log(`Hello ${name}!`);
}

const app = express();

// Cloud Scheduler requests contain JSON using 
"Content-Type: text/plain"
app.use(json({ type: '*/*' }));

app.enable('trust proxy'); // To respect X-Forwarded-For header. (Cloud Run is behind a load balancer proxy)
app.disable('x-powered-by'); // Disables the 'x-powered-by' header added by express (best practice)

app.post('/*', (req, res) => {
  const body = req.body;
  
  if (!body) {
    res.status(400).send('no Pub/Sub message received');
    return;
  }
  
  if (typeof body !== "object" || body.message === undefined) {
    res.status(400).send('invalid Pub/Sub message format');
    return;
  }

  const context = {
    eventId: body.message.messageId,
    timestamp: body.message.publishTime || new Date().toISOString(),
    eventType: PUBSUB_EVENT_TYPE,
    resource: {
      service: PUBSUB_SERVICE,
      type: PUBSUB_MESSAGE_TYPE,
      name: extractPubSubTopic(req.path),
    },
  };

  // for storing parsed form of body.message.data
  let _jsonData = undefined;

  const data = {
    '@type': PUBSUB_MESSAGE_TYPE,
    data: body.message.data,
    attributes: body.message.attributes || {},
    get json() {
      if (_jsonData === undefined) {
        const decodedString = Buffer.from(base64encoded, 'base64')
          .toString('utf8');
        try {
          _jsonData = JSON.parse(decodedString);
        } catch (parseError) {
          // fallback to raw string
          _jsonData = decodedString;
        }
      }
      return _jsonData;
    }
  };

  Promise.resolve()
    .then(() => handler(data, context))
    .then(
      () => {
        // finished without error
        // the return value of `handler` is ignored because
        // this isn't a callable function
        res.sendStatus(204); // No content
      },
      (err) => {
        // handler threw error
        console.error(err.stack);
        res.set('X-Google-Status', 'error');
        // Send back the error's message (as calls to this endpoint
        // are authenticated project users/service accounts)
        res.send(err.message);
      }
    )
});

const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`helloworld: listening on port ${port}`);
});

推荐阅读