如何使用 Cloud Build 將 GitHub 中的變更自動部署至 Cloud Run

1. 簡介

總覽

在本程式碼研究室中,您將設定 Cloud Run,在每次將原始碼變更推送至 GitHub 存放區時,自動建構及部署新版應用程式。

這個示範應用程式會將使用者資料儲存到 Firestore,但正確儲存僅有部分資料。您可以設定持續部署作業,這樣一來,當您將錯誤修正推送至 GitHub 存放區時,系統會自動在新修訂版本中看見修正內容。

課程內容

  • 使用 Cloud Shell 編輯器編寫 Express 網頁應用程式
  • 將 GitHub 帳戶連結至 Google Cloud,即可持續部署
  • 將應用程式自動部署至 Cloud Run
  • 瞭解如何使用 HTMX 和 TailwindCSS

2. 設定和需求

必要條件

啟用 Cloud Shell

  1. 在 Cloud 控制台中,按一下「啟用 Cloud Shell」圖示 d1264ca30785e435.png

cb81e7c8e34bc8d.png

如果您是第一次啟動 Cloud Shell,系統會顯示中繼畫面,說明這項服務的內容。如果系統顯示中繼畫面,請按一下「繼續」

d95252b003979716.png

佈建並連線至 Cloud Shell 只需幾分鐘的時間。

7833d5e1c5d18f54.png

這個虛擬機器已載入所有必要的開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。在本程式碼研究室中,您的大部分作業都可透過瀏覽器完成。

連線至 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

本程式碼研究室需要使用下列 API。您可以執行下列指令來啟用這些 API:

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

設定環境變數

您可以設定將在本程式碼研究室中使用的環境變數。

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. 您可以選擇在本程式碼研究室中啟用 Google Analytics。
  7. 按一下「新增 Firebase」
  8. 專案建立完成後,按一下「Continue」
  9. 在「建構」選單中,按一下「Firestore 資料庫」
  10. 按一下 [Create database] (建立資料庫)。
  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. 在本機執行應用程式

在本節中,您將在本機執行應用程式,確認使用者嘗試儲存資料時,應用程式存在錯誤。

首先,您必須具備 Datastore 使用者角色,才能存取 Firestore (如果使用身分進行驗證,例如透過 Cloud Shell 執行),也可以模擬先前建立的使用者帳戶。

在本機執行時使用 ADC

如果您是在 Cloud Shell 中執行,代表已經在 Google Compute Engine 虛擬機器上執行。應用程式預設憑證 (ADC) 會自動使用與這個虛擬機器相關聯的憑證 (由執行 gcloud auth list 顯示的憑證),因此您無需使用 gcloud auth application-default login 指令。不過,您的身分仍需具備 Datastore User 角色。請直接跳到「在本機執行應用程式」一節。

不過,如果您是在本機終端機 (而非 Cloud Shell 中) 執行應用程式,則必須使用應用程式預設憑證向 Google API 進行驗證。您可以使用憑證 (前提是您已具備 Datastore 使用者角色) 登入;或者,您可以模擬在本程式碼研究室中使用的服務帳戶,藉此登入。

做法 1) 針對 ADC 使用憑證

如要使用憑證,可以先執行 gcloud auth list 以驗證您在 gcloud 中的驗證方式。接下來,您可能需要向身分授予「Vertex AI 使用者」角色。如果您的身分具備「擁有者」角色,則您已經擁有這個 Datastore 使用者角色。如果沒有,您可以執行以下指令,授予身分識別 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 的服務帳戶

如要使用在本程式碼研究室中建立的服務帳戶,您的使用者帳戶必須具備服務帳戶權杖建立者角色。如要取得這個角色,請執行下列指令:

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

在本機執行應用程式

接下來,請確認您位於程式碼研究室的根目錄 cloud-run-github-cd-demo

cd .. && pwd

現在您將安裝依附元件。

npm install

最後,您可以執行以下指令來啟動應用程式。這個指令碼也會產生 tailwindCSS 的 output.css 檔案。

npm run dev

現在,開啟網路瀏覽器並前往 http://localhost:8080。如果您使用 Cloud Shell,可以開啟網站,方法是開啟「網頁預覽」按鈕,然後選取「預覽通訊埠 8080」。

網頁預覽 - 可透過通訊埠 8080 按鈕預覽

輸入名稱和城鎮輸入欄位的文字,然後按一下「儲存」。然後重新整理頁面。您會發現鎮區欄位並未保留。您將在後續章節中修正此錯誤。

停止本機執行速成應用程式 (例如在 MacOS 上為 Ctrl^c 鍵)。

8. 建立 GitHub 存放區

在本機目錄中,建立以 main 為預設分支版本名稱的新存放區。

git init
git branch -M main

提交包含錯誤的現有程式碼集。設定持續部署之後,您將修正錯誤。

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

前往 GitHub 建立僅限您本人或公開的空白存放區。本程式碼研究室建議為存放區命名 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 控制台

  • 按一下「建立服務」
  • 按一下「持續從存放區部署」
  • 按一下「設定雲端建構」
  • 位於原始碼存放區
    • 選取 GitHub 做為存放區供應商
    • 點選「管理已連結的存放區」,設定存放區的 Cloud Build 存取權
    • 選取您的存放區,然後點選「Next」(下一步)
  • 在「Build Configuration」(建構設定)
    • 將分支版本保留為 ^main$
    • 在「建構類型」部分,請選取「Go、Node.js、Python、Java、.NET Core、Ruby 或 PHP (透過 Google Cloud 的 buildpacks」)
  • 將 Build 結構定義目錄保留為 /
  • 點選「儲存」
  • 在「驗證」下
    • 按一下「允許未經驗證的叫用」
  • 在「容器」、「磁碟區」、「網路」、「安全性」下
    • 在「安全性」分頁下方,選取先前步驟中建立的服務帳戶 (例如Cloud Run access to Firestore
  • 按一下 [Create]

這麼做會部署 Cloud Run 服務,當中含有您將在下一節中修正的錯誤。

10. 修正錯誤

修正程式碼中的錯誤

在 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 Run 服務,監控部署作業的變更。

在實際工作環境中驗證修正結果

Cloud Run 服務的 Cloud 控制台顯示第 2 個修訂版本後,現在就會處理 100% 的流量,例如:https://console.cloud.google.com/run/detail/<YOUR_REGION>/<YOUR_SERVICE_NAME>/revisions,您可以在瀏覽器中開啟 Cloud Run 服務網址,並在重新整理頁面後,驗證新輸入的城鎮資料會保留下來。

11. 恭喜!

恭喜您完成本程式碼研究室!

建議您查看 Cloud Run 的說明文件和透過 Git 進行持續部署的說明文件。

涵蓋內容

  • 使用 Cloud Shell 編輯器編寫 Express 網頁應用程式
  • 將 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 控制台,然後刪除您在本程式碼研究室中建立的 Cloud Run 服務,例如:刪除 cloud-run-auto-deploy-codelab 服務。

如果選擇刪除整個專案,您可以前往 https://console.cloud.google.com/cloud-resource-manager,選取您在步驟 2 建立的專案,然後選擇「刪除」。如果刪除專案,您必須變更 Cloud SDK 中的專案。您可以執行 gcloud projects list 來查看可用專案的清單。