Cloud Build を使用して GitHub から Cloud Run に変更を自動的にデプロイする方法

1. はじめに

概要

この Codelab では、ソースコードの変更を GitHub リポジトリに push するたびに、アプリケーションの新しいバージョンを 自動的にビルドしてデプロイするように Cloud Run を構成します。

このデモ アプリケーションはユーザーデータを Firestore に保存しますが、データの一部しか正しく保存されません。バグ修正を GitHub リポジトリに push すると、新しいリビジョンで修正が自動的に利用できるようになるように、継続的デプロイを構成します。

学習内容

  • Cloud Shell エディタで Express ウェブ アプリケーションを作成する
  • 継続的デプロイのために GitHub アカウントを Google Cloud に接続する
  • アプリケーションを Cloud Run に自動的にデプロイする
  • HTMX と TailwindCSS の使用方法を学習する

2. 設定と要件

前提条件

Cloud Shell をアクティブにする

  1. Cloud Console で、[Cloud Shell をアクティブにする ] をクリックしますd1264ca30785e435.png

cb81e7c8e34bc8d.png

Cloud Shell を初めて起動した場合は、その内容を説明する画面が表示されます。その場合は、[続行] をクリックしてください。

d95252b003979716.png

すぐにプロビジョニングが実行され、Cloud Shell に接続されます。

7833d5e1c5d18f54.png

この仮想マシンには、必要な開発ツールがすべて用意されています。仮想マシンは Google Cloud で稼働し、永続的なホーム ディレクトリが 5 GB 用意されているため、ネットワークのパフォーマンスと認証が大幅に向上しています。 この 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 アナリティクスを有効にするかどうかは任意です。
  7. [Firebase を追加] をクリックします。
  8. プロジェクトが作成されたら、[続行] をクリックします。
  9. [ビルド] メニューから [Firestore データベース] をクリックします。
  10. [データベースを作成] をクリックします。
  11. [ロケーション] プルダウンからリージョンを選択し、[次へ] をクリックします。
  12. デフォルトの [**本番環境モードで開始**] を使用し、[**作成**] をクリックします。

6. アプリケーションを作成する

まず、ソースコードのディレクトリを作成し、そのディレクトリに移動します。

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. アプリケーションをローカルで実行する

このセクションでは、アプリケーションをローカルで実行して、ユーザーがデータを保存しようとしたときにアプリケーションにバグがあることを確認します。

まず、Firestore にアクセスするには、Datastore ユーザーロールが必要です(認証に ID を使用する場合、Cloud Shell で実行している場合など)。または、以前に作成したユーザー アカウントの権限を借用できます。

ローカルで実行する場合に ADC を使用する

Cloud Shell で実行している場合は、Google Compute Engine 仮想マシンで実行されています。この仮想マシンに関連付けられている認証情報(gcloud auth list を実行すると表示されます)は、アプリケーションのデフォルト認証情報(ADC)によって自動的に使用されるため、gcloud auth application-default login コマンドを使用する必要はありません。ただし、ID には Datastore ユーザーロールが必要です。アプリをローカルで実行する セクションまでスキップできます。

ただし、ローカル ターミナルで実行している場合(Cloud Shell でない場合)は、アプリケーションのデフォルト認証情報を使用して Google API を認証する必要があります。1)認証情報を使用してログインする(Datastore ユーザーロールがある場合)、または 2)この Codelab で使用するサービス アカウントの権限を借用してログインできます。

オプション 1)認証情報を使用して ADC を使用する

認証情報を使用する場合は、まず gcloud auth list を実行して、gcloud での認証方法を確認します。次に、ID に Vertex AI ユーザーロールを付与する必要がある場合があります。ID にオーナーロールがある場合は、この Datastore ユーザーロールがすでに付与されています。そうでない場合は、次のコマンドを実行して、ID に 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 で作成したサービス アカウントを使用する場合は、ユーザー アカウントに サービス アカウント トークン作成者のロールが必要です。このロールを取得するには、次のコマンドを実行します。

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 でプレビュー] ボタン

名前と町の入力フィールドにテキストを入力して、[保存] をクリックします。ページを再読み込みします。町のフィールドが保持されていないことがわかります。このバグは次のセクションで修正します。

ローカルでの Express アプリの実行を停止します(例: MacOS で Ctrl^c)。

8. GitHub リポジトリを作成する

ローカル ディレクトリに、デフォルトのブランチ名として main を使用して新しいリポジトリを作成します。

git init
git branch -M main

バグを含む現在のコードベースを commit します。継続的デプロイが構成されたら、バグを修正します。

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

GitHub に移動し、非公開または公開の空のリポジトリを作成します。この Codelab では、リポジトリに cloud-run-auto-deploy-codelab という名前を付けることをおすすめします。空のリポジトリを作成するには、デフォルトの設定をすべてオフにするか、[なし] に設定 して、作成時にデフォルトでリポジトリにコンテンツが含まれないようにします。例:

GitHub のデフォルト設定

この手順を正しく完了すると、空のリポジトリ ページに次の手順が表示されます。

空の GitHub リポジトリの手順

次のコマンドを実行して、コマンドラインから既存のリポジトリを push する 手順に沿って操作します。

まず、次のコマンドを実行してリモート リポジトリを追加します。

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

次に、main ブランチをアップストリーム リポジトリに push します。

git push -u origin main

9. 継続的デプロイを設定する

GitHub にコードが用意できたので、継続的デプロイを設定できます。Cloud Run の Cloud コンソールに移動します。

  • [サービスを作成] をクリックします。
  • [リポジトリから継続的にデプロイする] をクリックします。
  • [CLOUD BUILD を設定] をクリックします。
  • [ソース リポジトリ] で次の操作を行います。
    • [リポジトリ プロバイダ] で [GitHub] を選択します。
    • [接続されたリポジトリを管理] をクリックして、リポジトリへの Cloud Build アクセスを構成します。
    • リポジトリを選択して [次へ] をクリックします。
  • [ビルド構成] で次の操作を行います。
    • [ブランチ] は ^main$ のままにします。
    • [ビルドタイプ] で [Go、Node.js、Python、Java、.NET Core、Ruby、PHP(Google Cloud の Buildpacks を使用)] を選択します。
  • [ビルド コンテキストのディレクトリ] は / のままにします。
  • [保存] をクリックします。
  • [認証] で次の操作を行います。
    • [未認証の呼び出しを許可] をクリックします。
  • [コンテナ、ボリューム、ネットワーキング、セキュリティ] で次の操作を行います。
    • [セキュリティ] タブで、前の手順で作成したサービス アカウント(Cloud Run access to Firestore など)を選択します。
  • [作成] をクリックします。

これにより、次のセクションで修正するバグを含む 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

ウェブブラウザを開きます。町のデータをもう一度保存して、更新します。更新時に、新しく入力した町のデータが正しく保持されていることがわかります。

修正を確認したので、デプロイする準備ができました。まず、修正を commit します。

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

GitHub のアップストリーム リポジトリに push します。

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 サービスの URL を開き、ページを更新した後、新しく入力した町のデータが保持されていることを確認します。

11. 完了

以上で、この Codelab は完了です。

Cloud RunGit からの継続的デプロイのドキュメントを確認することをおすすめします。

学習した内容

  • Cloud Shell エディタで Express ウェブ アプリケーションを作成する
  • 継続的デプロイのために 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 を実行すると、使用可能なすべてのプロジェクトのリストが表示されます。