如何使用 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 Console。
  • 您之前已部署 Cloud Run 服务。例如,您可以按照从源代码部署 Web 服务快速入门中的步骤开始操作。

激活 Cloud Shell

  1. 在 Cloud Console 中,点击 激活 Cloud Shell d1264ca30785e435.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 读取 Secret 的权限。

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

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 目录中,为前端创建一个 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 虚拟机上运行。与此虚拟机关联的凭证(如运行 gcloud auth list 所示)将由 应用默认凭证 (ADC) 自动使用,因此无需使用 gcloud auth application-default login 命令。不过,您的身份仍然需要 Datastore User 角色。您可以跳到在本地运行应用 部分。

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

选项 1) 使用您的凭据进行 ADC

如果您想使用自己的凭据,可以先运行 gcloud auth list 来验证您在 gcloud 中的身份验证方式。接下来,您可能需要为您的身份授予 Vertex AI User 角色。如果您的身份具有 Owner 角色,则您已拥有此 Datastore User 用户角色。否则,您可以运行此命令来为您的身份授予 Vertex AI User 角色和 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 上预览”按钮

为名称和城镇输入字段输入文本,然后点击“保存”。然后刷新页面。您会注意到,城镇字段没有保留。您将在后续部分中修复此 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 Console

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

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

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 Console 来监控部署更改。

在生产环境中验证修复

Cloud Run 服务的 Cloud 控制台显示第二个修订版本现在提供 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 Console (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 查看所有可用项目的列表。