前言: 以下步骤已针对更广泛的受众进行了推广,而不仅仅是 OP 的问题(涵盖 HTTP 事件、计划和 Pub/Sub 函数),并改编自问题中链接的文档:Deploying Node.JS Images on Cloud Run.
第 0 步:代码/架构审查
通常情况下,超过 9 分钟的 Cloud Function 超时是由于代码中的错误导致的 - 请务必在切换到 Cloud Run 之前对此进行评估,因为这只会使问题变得更糟。其中最常见的是顺序异步处理而不是并行异步处理(通常由在for/while 循环中使用await 引起)。
如果您的代码正在执行需要很长时间的有意义的工作,请考虑将其分片到可以全部并行处理输入数据的“子函数”。您可以使用单个函数来触发一个函数的多个实例,而不是为数据库中的每个用户处理数据,每个实例处理不同的用户 ID 范围,例如 a-l\uf8ff、m-z\uf8ff、A-L\uf8ff、@987654353 @和0-9\uf8ff。
最后,Cloud Run 和 Cloud Functions 非常相似,它们旨在接受请求,处理它,然后返回响应。 Cloud Functions 的限制时间最长为 9 分钟,Cloud Runs 的限制时间最长为 60 分钟。一旦该响应完成(因为服务器结束响应、客户端丢失连接或客户端中止请求),实例被严重限制或终止。虽然您可以在使用 Cloud Run 时使用 WebSockets 和 gRPC 在服务器和客户端之间进行持久通信,但它们仍然受到此限制。有关详细信息,请参阅 Cloud Run: General development tips 文档。
与其他无服务器解决方案一样,您的客户端和服务器需要能够处理与不同实例的连接。您的代码不应使用本地状态(如会话数据的本地存储)。有关详细信息,请参阅 Setting request timeout 文档。
第 1 步:安装 Google Cloud SDK
这一步我会推荐你到Installing Google Cloud SDK documentation。
安装后,致电gcloud auth login 并使用用于目标 Firebase 项目的帐户登录。
第 2 步:获取您的 Firebase 项目设置
打开您的project settings in the Firebase Console 并记下您的项目 ID 和您的默认 GCP 资源位置。
Firebase Functions 和 Cloud Run 实例应尽可能与您的 GCP 资源位于同一位置。在 Firebase Functions 中,这是通过 changing the region in code 实现的,并使用 CLI 进行部署。对于 Cloud Run,您可以在命令行上将这些参数指定为标志(或使用 Google Cloud Console)。对于以下说明并为简单起见,我将使用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: |
PROJECT_ID |
| Database URL: |
https://PROJECT_ID.firebaseio.com |
| Storage Bucket: |
PROJECT_ID.appspot.com |
| Default GCP Resource Location: |
|
|
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
第四步:创建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_NAME、REGION_ID 和 PROJECT_ID 将根据步骤 2 中的详细信息进行适当替换。我们还安装了 express 和 body-parser 来处理传入的请求。
还有一些模块脚本可以帮助部署。
| Script Name |
Description |
image |
Submits the image to Cloud Build to be added to the Container Registry for other commands. |
deploy:public |
Deploys the image from the above command to be used by Cloud Run (while allowing any requester to invoke it) and returns its service URL (which is partly randomized). |
deploy:private |
Deploys the image from the above command to be used by Cloud Run (while requiring that the requester that invokes it is an authorized user/service account) and returns its service URL (which is partly randomized). |
describe |
Gets the statistics & configuration of the deployed Cloud Run. |
find |
Extracts only the service URL from the response of npm run describe
|
注意:这里,“授权用户”是指与项目关联的 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 environment variable 指定它希望您的代码监听的端口。
变体:迁移 HTTP 事件函数
当您使用来自firebase-functions 包的HTTP Event function 时,它会在内部处理body-parsing on your behalf。 Functions Framework 为此使用 body-parser package 和 defines the parsers here。
要处理用户授权,您可以使用此validateFirebaseIdToken() 中间件来检查请求中提供的 ID 令牌。
对于基于 HTTP 的 Cloud Run,需要配置 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
变体:使用 Cloud Scheduler 调用
使用 Cloud Scheduler 调用 Cloud Run 时,您可以选择使用哪种方法来调用它(GET、POST(默认)、PUT、HEAD、DELETE)。要复制 Cloud Function 的 data 和 context 参数,最好使用 POST,因为这些参数将在请求正文中传递。与 Firebase Functions 一样,来自 Cloud Scheduler 的这些请求可能会被重试,因此请务必发送至 handle idempotency appropriately。
注意:即使 Cloud Scheduler 调用请求的主体是 JSON 格式的,该请求也会使用 Content-Type: text/plain 提供服务,我们需要处理它。
这段代码改编自Functions Framework source(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 响应来处理错误。这实际上意味着“成功失败”。作为一个局外人,我不确定为什么要这样做,但我可以假设它是为了让调用者知道不必费心重试该函数——它只会得到相同的结果。
在$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_NAME、SERVICE_URL 和 IAM_ACCOUNT 需要酌情替换。
接下来我们需要create a service account,Cloud Scheduler 可以使用它来调用 Cloud Run。您可以随意调用它,例如scheduled-run-invoker。此服务帐户的电子邮件将在下一步中称为IAM_ACCOUNT。这个Google Cloud Tech YouTube video(从正确的位置开始,大约 15 秒)将快速显示您需要做什么。创建帐户后,您可以在接下来的 30 秒左右的视频之后 create the Cloud Scheduler job 或使用以下命令:
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 source(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}`);
});