如何使用 Cloud Build 自动将更改从 GitHub 部署到 Cloud Run

1. 简介

概览

在此 Codelab 中,您将对 Cloud Run 进行配置,使其在您将源代码更改推送到 GitHub 代码库时自动构建和部署应用的新版本。

此演示应用将用户数据保存到 Firestore,但是,只有部分数据正确保存。您将配置持续部署,这样当您将 bug 修复推送到 GitHub 代码库时,会自动看到该修复在新的修订版本中提供。

学习内容

  • 使用 Cloud Shell Editor 编写 Express Web 应用
  • 将您的 GitHub 账号关联到 Google Cloud 以进行持续部署
  • 自动将应用部署到 Cloud Run
  • 了解如何使用 HTMX 和 TailwindCSS

2. 设置和要求

前提条件

  • 您有一个 GitHub 账号,并且熟悉如何创建代码以及将代码推送到代码库。
  • 您已登录 Cloud 控制台。
  • 您之前已部署了 Cloud Run 服务。例如,您可以按照快速入门:部署 Web 服务开始操作。

激活 Cloud Shell

  1. 在 Cloud Console 中,点击激活 Cloud Shelld1264ca30785e435.png

cb81e7c8e34bc8d.png

如果这是您第一次启动 Cloud Shell,系统会显示一个中间屏幕,说明它是什么。如果您看到中间屏幕,请点击继续

d95252b003979716.png

预配和连接到 Cloud Shell 只需花几分钟时间。

7833d5e1c5d18f54

这个虚拟机装有所需的所有开发工具。它提供了一个持久的 5 GB 主目录,并在 Google Cloud 中运行,大大增强了网络性能和身份验证功能。您在此 Codelab 中的大部分(即使不是全部)工作都可以通过浏览器完成。

在连接到 Cloud Shell 后,您应该会看到自己已通过身份验证,并且相关项目已设为您的项目 ID。

  1. 在 Cloud Shell 中运行以下命令以确认您已通过身份验证:
gcloud auth list

命令输出

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. 在 Cloud Shell 中运行以下命令,以确认 gcloud 命令了解您的项目:
gcloud config list project

命令输出

[core]
project = <PROJECT_ID>

如果不是上述结果,您可以使用以下命令进行设置:

gcloud config set project <PROJECT_ID>

命令输出

Updated property [core/project].

3. 启用 API 并设置环境变量

启用 API

此 Codelab 需要使用以下 API。您可以通过运行以下命令来启用这些 API:

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    firestore.googleapis.com \
    iamcredentials.googleapis.com

设置环境变量

您可以设置要在整个 Codelab 中使用的环境变量。

REGION=<YOUR-REGION>
PROJECT_ID=<YOUR-PROJECT-ID>
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
SERVICE_ACCOUNT="firestore-accessor"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

4. 创建服务账号

Cloud Run 将使用此服务账号调用 Vertex AI Gemini API。此服务账号还有权对 Firestore 执行读写操作,以及从 Secret Manager 读取密钥。

首先,通过运行以下命令创建服务账号:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run access to Firestore"

现在,向该服务账号授予对 Firestore 的读写权限。

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

5. 创建和配置 Firebase 项目

  1. Firebase 控制台中,点击添加项目
  2. 输入 <YOUR_PROJECT_ID>将 Firebase 添加到您的一个现有 Google Cloud 项目中
  3. 如果系统提示,请查看并接受 Firebase 条款
  4. 点击继续
  5. 点击确认方案以确认 Firebase 结算方案。
  6. 您可以选择为此 Codelab 启用 Google Analytics。
  7. 点击添加 Firebase
  8. 创建项目后,点击继续
  9. 构建菜单中,点击 Firestore 数据库
  10. 点击创建数据库
  11. 位置下拉列表中选择您的区域,然后点击下一步
  12. 使用默认的以生产模式开始,然后点击创建

6. 编写应用

首先,为源代码创建一个目录,然后通过 cd 命令进入该目录。

mkdir cloud-run-github-cd-demo && cd $_

然后,创建一个包含以下内容的 package.json 文件:

{
  "name": "cloud-run-github-cd-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "tailwind-dev": "npx tailwindcss -i ./input.css -o ./public/output.css --watch",
    "tailwind": "npx tailwindcss -i ./input.css -o ./public/output.css",
    "dev": "npm run tailwind && npm run nodemon"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/firestore": "^7.3.1",
    "axios": "^1.6.7",
    "express": "^4.18.2",
    "htmx.org": "^1.9.10"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "tailwindcss": "^3.4.1"
  }
}

首先,创建一个包含以下内容的 app.js 源文件。此文件包含服务的入口点以及应用的主要逻辑。

const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const path = require("path");
const { get } = require("axios");

const { Firestore } = require("@google-cloud/firestore");
const firestoreDb = new Firestore();

const fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

const service = process.env.K_SERVICE;
const revision = process.env.K_REVISION;

app.use(express.static("public"));

app.get("/edit", async (req, res) => {
    res.send(`<form hx-post="/update" hx-target="this" hx-swap="outerHTML">
                <div>
  <p>
    <label>Name</label>    
    <input class="border-2" type="text" name="name" value="Cloud">
    </p><p>
    <label>Town</label>    
    <input class="border-2" type="text" name="town" value="Nibelheim">
    </p>
  </div>
  <div class="flex items-center mr-[10px] mt-[10px]">
  <button class="btn bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]">Submit</button>
  <button class="btn bg-gray-200 text-gray-800 px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]" hx-get="cancel">Cancel</button>  
                ${spinnerSvg} 
                </div>
  </form>`);
});

app.post("/update", async function (req, res) {
    let name = req.body.name;
    let town = req.body.town;
    const doc = firestoreDb.doc(`demo/${name}`);

    //TODO: fix this bug
    await doc.set({
        name: name
        /* town: town */
    });

    res.send(`<div hx-target="this" hx-swap="outerHTML" hx-indicator="spinner">
                <p>
                <div><label>Name</label>: ${name}</div>
                </p><p>
                <div><label>Town</label>: ${town}</div>
                </p>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>               
            </div>`);
});

app.get("/cancel", (req, res) => {
    res.send(`<div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>`);
});

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

    //serviceMetadata = helper();
});

app.get("/helper", async (req, res) => {
    let region = "";
    let projectId = "";
    let div = "";

    try {
        // Fetch the token to make a GCF to GCF call
        const response1 = await get(
            "http://metadata.google.internal/computeMetadata/v1/project/project-id",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        // Fetch the token to make a GCF to GCF call
        const response2 = await get(
            "http://metadata.google.internal/computeMetadata/v1/instance/region",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        projectId = response1.data;
        let regionFull = response2.data;
        const index = regionFull.lastIndexOf("/");
        region = regionFull.substring(index + 1);

        div = `
        <div>
        This created the revision <code>${revision}</code> of the 
        Cloud Run service <code>${service}</code> in <code>${region}</code>
        for project <code>${projectId}</code>.
        </div>`;
    } catch (ex) {
        // running locally
        div = `<div> This is running locally.</div>`;
    }

    res.send(div);
});

创建一个名为 spinnerSvg.js 的文件

module.exports.spinnerSvg = `<svg id="spinner" alt="Loading..."
                    class="htmx-indicator animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                >
                    <circle
                        class="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                    ></circle>
                    <path
                        class="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path>
                </svg>`;

为 tailwindCSS 创建 input.css 文件

@tailwind base;
@tailwind components;
@tailwind utilities;

然后为 tailwindCSS 创建 tailwind.config.js 文件

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

并创建一个 .gitignore 文件。

node_modules/

npm-debug.log
coverage/

package-lock.json

.DS_Store

现在,创建一个新的 public 目录。

mkdir public
cd public

在该公共目录中,为前端创建 index.html 文件,该文件将使用 htmx。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <script
            src="https://unpkg.com/htmx.org@1.9.10"
            integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
            crossorigin="anonymous"
        ></script>

        <link href="./output.css" rel="stylesheet" />
        <title>Demo 1</title>
    </head>
    <body
        class="font-sans bg-body-image bg-cover bg-center leading-relaxed"
    >
        <div class="container max-w-[700px] mt-[50px] ml-auto mr-auto">
            <div class="hero flex items-center">                    
                <div class="message text-base text-center mb-[24px]">
                    <h1 class="text-2xl font-bold mb-[10px]">
                        It's running!
                    </h1>
                    <div class="congrats text-base font-normal">
                        Congratulations, you successfully deployed your
                        service to Cloud Run. 
                    </div>
                </div>
            </div>

            <div class="details mb-[20px]">
                <p>
                    <div hx-trigger="load" hx-get="/helper" hx-swap="innerHTML" hx-target="this">Hello</div>                   
                </p>
            </div>

            <p
                class="callout text-sm text-blue-700 font-bold pt-4 pr-6 pb-4 pl-10 leading-tight"
            >
                You can deploy any container to Cloud Run that listens for
                HTTP requests on the port defined by the
                <code>PORT</code> environment variable. Cloud Run will
                scale automatically based on requests and you never have to
                worry about infrastructure.
            </p>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                Persistent Storage Example using Firestore
            </h1>
            <div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                What's next
            </h1>
            <p class="next text-base mt-4 mb-[20px]">
                You can build this demo yourself!
            </p>
            <p class="cta">
                <button
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                >
                    VIEW CODELAB
                </button>
            </p> 
        </div>
   </body>
</html>

7. 在本地运行应用

在本部分中,您将在本地运行应用,以确认当用户尝试保存数据时应用是否存在 bug。

首先,您需要拥有 Datastore User 角色才能访问 Firestore(如果使用您的身份进行身份验证,例如在 Cloud Shell 中运行),或者您可以模拟之前创建的用户账号。

在本地运行时使用 ADC

如果您在 Cloud Shell 中运行,那么您已经在 Google Compute Engine 虚拟机上运行。应用默认凭据 (ADC) 会自动使用与此虚拟机关联的凭据(如运行 gcloud auth list 所示),因此您无需使用 gcloud auth application-default login 命令。不过,您的身份仍然需要 Datastore User 角色。您可以跳至在本地运行应用部分。

但是,如果您是在本地终端上运行(即未在 Cloud Shell 中运行),则需要使用应用默认凭据向 Google API 进行身份验证。您可以 1) 使用凭据登录(前提是您拥有 Datastore 用户角色)或 2) 通过模拟此 Codelab 中使用的服务账号来登录。

选项 1) 将您的 ADC 凭据用于 ADC

如果您想使用自己的凭据,可以先运行 gcloud auth list 来验证您在 gcloud 中是如何进行身份验证的。接下来,您可能需要向自己的身份授予 Vertex AI User 角色。如果您的身份具有 Owner 角色,则表明您已拥有此 Datastore User 用户角色。如果没有,您可以运行此命令来授予您的身份 Vertex AI 用户角色和 Datastore User 角色。

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/datastore.user

然后运行以下命令

gcloud auth application-default login

选项 2) 为 ADC 模拟服务账号

如果您想使用在此 Codelab 中创建的服务账号,您的用户账号需要具备 Service Account Token Creator 角色。您可以通过运行以下命令来获取此角色:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/iam.serviceAccountTokenCreator

接下来,您将运行以下命令,以通过服务账号使用 ADC

gcloud auth application-default login --impersonate-service-account=$SERVICE_ACCOUNT_ADDRESS

在本地运行应用

接下来,确保您位于此 Codelab 的根目录 cloud-run-github-cd-demo 中。

cd .. && pwd

现在,您需要安装依赖项。

npm install

最后,您可以通过运行以下脚本来启动应用。此脚本还会通过 tailwindCSS 生成 output.css 文件。

npm run dev

现在,打开网络浏览器并访问 http://localhost:8080。如果您使用的是 Cloud Shell,则可以通过以下方式打开该网站:打开“网页预览”按钮,然后选择“预览端口 8080”。

网页预览 -“在端口 8080 上预览”按钮

在名称和城镇输入字段中输入文本,然后点击“保存”。然后刷新页面。您会发现“town”字段没有保留。您将在下一部分中修复此 bug。

停止在本地运行 Express 应用(如在 MacOS 上按 Ctrl^c)。

8. 创建 GitHub 代码库

在本地目录中,创建一个新代码库,并将 main 作为默认分支名称。

git init
git branch -M main

提交包含 bug 的当前代码库。您将在配置持续部署后修复该 bug。

git add .
git commit -m "first commit for express application"

转到 GitHub 并创建一个仅对您可见或公开的空代码库。此 Codelab 建议您将仓库命名为 cloud-run-auto-deploy-codelab。要创建一个空仓库,您需要取消选中所有默认设置或设为无,这样创建时,仓库中就不会默认包含任何内容,例如:

GitHub 默认设置

如果您正确完成了此步骤,则会在空白代码库页面上看到以下说明:

GitHub 代码库说明为空

您需要运行以下命令,以按照从命令行推送现有代码库的说明执行操作:

首先,通过运行以下命令添加远程代码库

git remote add origin <YOUR-REPO-URL-PER-GITHUB-INSTRUCTIONS>

然后将主分支推送到上游仓库

git push -u origin main

9. 设置持续部署

现在,您在 GitHub 中拥有了代码,接下来可以设置持续部署了。前往适用于 Cloud Run 的 Cloud 控制台

  • 点击“创建服务”
  • 点击从代码库持续部署
  • 点击设置 Cloud Build
  • 在源代码库
    • 选择 GitHub 作为代码库提供方
    • 点击管理关联的代码库,配置 Cloud Build 对代码库的访问权限
    • 选择您的代码库,然后点击下一步
  • 在“构建配置”
    • 将分支保留为 ^main$
    • 对于“Build Type”(构建类型),请选择 Go、Node.js、Python、Java、.NET Core、Ruby 或 PHP(通过 Google Cloud 的 Buildpack)
  • 将 build 上下文目录保留为 /
  • 点击保存
  • 在“身份验证”
    • 点击允许未通过身份验证的调用
  • 在“容器”、“卷”、“网络”、“安全性”下
    • 在“安全性”标签页下,选择您在之前步骤中创建的服务账号,例如Cloud Run access to Firestore
  • 点击创建 (CREATE)

这将部署包含将在下一部分中修复的 bug 的 Cloud Run 服务。

10. 修复 bug

修复代码中的 bug

在 Cloud Shell Editor 中,点击 app.js 文件并转到显示 //TODO: fix this bug 的注释

将以下代码行从

 //TODO: fix this bug
    await doc.set({
        name: name
    });

//fixed town bug
    await doc.set({
        name: name,
        town: town
    });

通过运行

npm run start

并打开网络浏览器。再次为城镇保存数据,然后刷新。您可以看到,新输入的城镇数据在刷新后已正确保留。

现在,您已经验证了修正效果,可以进行部署了。首先,提交修复程序。

git add .
git commit -m "fixed town bug"

然后将其推送到 GitHub 上的上游代码库。

git push origin main

Cloud Build 将自动部署您的更改。您可以转到 Cloud Run 服务的 Cloud 控制台,以监控部署更改。

在正式版中验证修复

一旦 Cloud Run 服务的 Cloud 控制台显示第 2 个修订版本现已开始处理 100% 流量,例如https://console.cloud.google.com/run/detail/<YOUR_REGION>/<YOUR_SERVICE_NAME>/revisions,您可以在浏览器中打开 Cloud Run 服务网址,并在刷新页面后验证新输入的城镇数据是否保留。

11. 恭喜!

恭喜您完成此 Codelab!

建议您查看 Cloud Run通过 Git 进行持续部署的文档。

所学内容

  • 使用 Cloud Shell Editor 编写 Express Web 应用
  • 将您的 GitHub 账号关联到 Google Cloud 以进行持续部署
  • 自动将应用部署到 Cloud Run
  • 了解如何使用 HTMX 和 TailwindCSS

12. 清理

为避免产生意外费用(例如,如果意外调用 Cloud Run 服务的次数超过免费层级中的每月 Cloud Run 调用次数),您可以删除 Cloud Run 或删除在第 2 步中创建的项目。

如需删除 Cloud Run 服务,请前往 https://console.cloud.google.com/run 前往 Cloud Run Cloud 控制台,然后删除您在此 Codelab 中创建的 Cloud Run 服务,例如删除 cloud-run-auto-deploy-codelab 服务。

如果您选择删除整个项目,可以前往 https://console.cloud.google.com/cloud-resource-manager,选择您在第 2 步中创建的项目,然后选择“删除”。如果删除项目,则需要在 Cloud SDK 中更改项目。您可以通过运行 gcloud projects list 来查看所有可用项目的列表。