javascript 遇到540s超时限制后,如何从Firebase Functions切换到Cloud Run?

gr8qqesn  于 2023-02-02  发布在  Java
关注(0)|答案(1)|浏览(97)

我正在阅读this Reddit thread,其中一个用户提到540s是Firebase功能的极限,建议转移到Cloud Run。
正如其他人所说,540s是最大超时时间,如果您希望在不对代码做太多其他更改的情况下增加它,请考虑迁移到Cloud Run。
在YouTube和Google上看了Node.JS QuickStart documentation和其他内容后,我没有找到一个很好的指南来解释如何将你的Firebase功能移到Cloud Run上。
我读到的内容没有涉及的一个问题,例如:我用什么替换firebase-functions包来定义函数?等等...
那么,我如何将我的Firebase功能移到Cloud Run中,才不会运行到540s的最大超时限制中呢?

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

ca1c2owp1#

    • 前言:**以下步骤已被推广到更广泛的受众,而不仅仅是OP的问题(包括HTTP事件、计划和发布/订阅功能),并改编自问题中链接的文档:Deploying Node.JS Images on Cloud Run.

步骤0:代码/体系结构评审

通常情况下,超过云函数9分钟的超时是代码中的bug导致的-请确保在切换到云运行之前评估此bug,因为这只会使问题更糟。其中最常见的是顺序而非并行异步处理(通常是在for/while循环中使用await导致的)。
如果您的代码正在执行有意义的工作,并且需要很长时间,请考虑将其划分为"子函数",这些子函数可以并行处理输入数据。您可以使用单个函数来触发函数的多个示例,而不是处理数据库中每个用户的数据,每个示例负责不同的用户ID范围,例如a-l\uf8ffm-z\uf8ffA-L\uf8ffM-Z\uf8ff0-9\uf8ff
最后,云运行和云函数非常相似,它们被设计为接受请求,处理请求,然后返回响应。云函数最多有9分钟的限制,云运行最多有60分钟的限制。一旦响应完成(因为服务器结束响应、客户端丢失连接或客户端中止请求),* * 示例被严重限制或终止**。虽然在使用Cloud Run时可以使用WebSockets和gRPC在服务器和客户端之间进行持久通信,但它们仍然受到此限制。有关详细信息,请参阅Cloud Run: General development tips文档。
像其他无服务器解决方案一样,客户端和服务器需要能够处理连接到不同示例的操作,代码不应该使用本地状态(如会话数据的本地存储),更多信息请参见Setting request timeout文档。

步骤1:安装Google云SDK

我建议您参考Installing Google Cloud SDK documentation来执行此步骤。
安装后,调用gcloud auth login并使用目标Firebase项目所用的帐户登录。

步骤2:获取Firebase项目设置

打开您的project settings in the Firebase Console,记下您的 * 项目ID * 和 * 默认GCP资源位置 *。
Firebase函数和云运行示例应尽可能与GCP资源位于同一位置。在Firebase函数中,这是通过更改代码中的区域并使用CLI部署来实现的。对于云运行,您可以在命令行上将这些参数指定为标志(或使用Google云控制台)。对于以下说明和简单起见,我将使用us-central1,因为我的 * 默认GCP资源位置 * 是nam5 (us-central)
如果在项目中使用Firebase实时数据库,请访问RTDB settings in the Firebase Console并记下 * 数据库URL *。通常格式为https://PROJECT_ID.firebaseio.com/
如果在您的项目中使用Firebase存储,请访问您的Cloud Storage settings in the Firebase Console并记录您的 * Bucket URI *。从该URI,我们需要记录主机(忽略gs://部分),其格式通常为PROJECT_ID.appspot.com
您可以复制以下表格以帮助跟踪:
| | |
| - ------|- ------|
| 项目编号:|PROJECT_ID|
| 数据库URL:|https://PROJECT_ID.firebaseio.com|
| 储存桶:|PROJECT_ID.appspot.com|
| 默认GCP资源位置:||
| Chosen Cloud Run Region:||

步骤3:创建目录

在Firebase项目目录或您选择的目录中,创建新的cloudrun文件夹。
与Firebase Cloud Functions不同,您可以在单个代码模块中定义多个函数,每个Cloud Run映像使用自己的代码模块。因此,每个Cloud Run映像都应存储在自己的目录中。
由于我们将定义一个名为helloworld的Cloud Run示例,因此我们将在cloudrun中创建一个名为helloworld的目录。

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|将映像提交到云构建以添加到容器注册表中,以便执行其他命令。|
| x1米30英寸1x|从上面的命令部署要由Cloud Run使用的映像(同时允许任何请求者调用它),并返回其服务URL(部分随机化)。|
| deploy:private|部署来自上述命令的映像以供Cloud Run使用(同时要求调用它的请求者是授权用户/服务帐户),并返回其服务URL(部分随机化)。|
| describe|获取已部署云运行的统计信息和配置。|
| find|仅从npm run describe的响应中提取服务URL|

    • 注意:**此处,"授权用户"指的是与项目关联的Google帐户,而非普通Firebase用户。要允许Firebase用户调用Cloud Run,您必须使用deploy:public部署它,并在Cloud 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事件函数

当你使用firebase-functions包中的一个HTTP Event function时,它会在内部处理主体解析,Functions Framework使用body-parser package并在这里定义解析器。
要处理用户授权,可以使用这个validateFirebaseIdToken()中间件检查请求中给出的ID令牌。
对于基于HTTP的云运行,需要配置CORS才能从浏览器调用它。这可以通过安装cors package并对其进行适当配置来完成。在以下示例中,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

变体:使用云调度程序调用

使用云调度程序调用云运行时,您可以选择用于调用它的方法(GETPOST(默认值)、PUTHEADDELETE)。要复制云函数的datacontext参数,最好使用POST,因为它们将在请求主体中传递。与Firebase函数一样,来自Cloud Scheduler的这些请求可能会重试,因此请确保使用handle idempotency appropriately

    • 注意:**尽管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}`);
});
    • 注意:**函数框架通过发送一个带有X-Google-Status: error头的HTTP 200 OK响应来处理错误。这实际上意味着"failed successfully"。作为一个局外人,我不知道为什么要这样做,但我可以假设这样做是为了让调用者知道不必费心重试函数-它只会得到相同的结果。

$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_IDSERVICE_NAMESERVICE_URLIAM_ACCOUNT将需要根据需要进行替换。

接下来,我们需要create a service account,Cloud Scheduler可以使用它来调用Cloud Run。您可以随意命名它,例如scheduled-run-invoker。在下一步中,此服务帐户的电子邮件将称为IAM_ACCOUNT。此Google Cloud Tech YouTube video(从正确的位置开始,大约15秒)将快速显示您需要执行的操作。创建帐户后,您可以create the Cloud Scheduler job跟随视频的下一个30秒左右,或者使用以下命令:

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

您的云跑步现在应该已安排好。

变体:使用发布/订阅调用

据我所知,部署过程与计划运行(deploy:private)相同,但我不确定具体细节。不过,下面是发布/订阅解析器的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}`);
});

相关问题