如何使用 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.png

此虚拟机已加载所需的所有开发工具。它提供了一个持久的 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

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

<!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 虚拟机上运行。与此虚拟机关联的凭据(如运行 gcloud auth list 所示)将由应用默认凭证 (ADC) 自动使用,因此无需使用 gcloud auth application-default login 命令。不过,您的身份仍需要具有 Datastore 用户角色。您可以跳至在本地运行应用部分。

不过,如果您在本地终端(即不在 Cloud Shell 中)上运行,则需要使用应用默认凭据向 Google API 进行身份验证。您可以选择以下任一方式登录:1) 使用您的凭据登录(前提是您具有 Datastore User 角色);2) 通过模拟此 Codelab 中使用的服务账号登录。

方法 1)使用您的凭据进行 ADC

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

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 上预览”按钮

在“姓名”和“城镇”输入字段中输入文本,然后点击“保存”。然后刷新页面。您会发现,城镇字段未保留。您将在后续部分中修复此 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 对代码库的访问权限
    • 选择您的代码库,然后点击下一步
  • 在“Build Configuration”(构建配置)下
    • 将“分支”保留为 ^main$
    • 对于“构建类型”,请选择 Go、Node.js、Python、Java、.NET Core、Ruby 或 PHP(通过 Google Cloud 的 Buildpack)
  • 将“构建上下文目录”保留为 /
  • 点击保存
  • 在“身份验证”下
    • 点击允许未通过身份验证的调用
  • 在“容器、卷、网络、安全性”下
    • 在“安全性”标签页下,选择您在之前的步骤中创建的服务账号,例如 Cloud Run access to Firestore
  • 点击创建 (CREATE)

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

10. 修复 bug

修复了代码中的 bug

在 Cloud Shell 编辑器中,打开 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 服务,请前往 Cloud Run Cloud 控制台 (https://console.cloud.google.com/run),然后删除您在本 Codelab 中创建的 Cloud Run 服务,例如删除 cloud-run-auto-deploy-codelab 服务。

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