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 用意されているため、ネットワークのパフォーマンスと認証が大幅に向上しています。このコードラボで行う作業のほとんどはブラウザから実行できます。

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> を入力して、既存の Google Cloud プロジェクトのいずれかに Firebase を追加します。
  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

その公開ディレクトリ内に、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. アプリケーションをローカルで実行する

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

まず、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

アプリをローカルで実行する

次に、コードラボのルート ディレクトリ cloud-run-github-cd-demo に移動します。

cd .. && pwd

次に、依存関係をインストールします。

npm install

最後に、次のスクリプトを実行してアプリを起動します。このスクリプトは、tailwindCSS から output.css ファイルも生成します。

npm run dev

ウェブブラウザで http://localhost:8080 を開きます。Cloud Shell を使用している場合は、[ウェブでプレビュー] ボタンを開き、[プレビューのポート: 8080] を選択してウェブサイトを開くことができます。

ウェブ プレビュー - [ポート 8080 でプレビュー] ボタン

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

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

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

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

git init
git branch -M main

バグを含む現在のコードベースをコミットします。このバグは、継続的デプロイを構成した後に修正します。

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>

次に、メインブランチをアップストリーム リポジトリに 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

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

修正を確認したら、デプロイする準備が整いました。まず、修正をコミットします。

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

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

git push origin main

Cloud Build が変更を自動的にデプロイします。Cloud Run サービスの Cloud Console に移動して、デプロイの変更をモニタリングできます。

本番環境で修正を確認する

Cloud Run サービスの Cloud コンソールに、2 番目のリビジョンが 100% のトラフィックを処理していることが表示されたら(例: https://console.cloud.google.com/run/detail/<YOUR_REGION>/<YOUR_SERVICE_NAME>/revisions)、ブラウザで Cloud Run サービスの URL を開き、ページを更新した後に新しく入力した町データが保持されていることを確認します。

11. 完了

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

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 Console に移動し、この Codelab で作成した Cloud Run サービス(cloud-run-auto-deploy-codelab サービスなど)を削除します。

プロジェクト全体を削除する場合は、https://console.cloud.google.com/cloud-resource-manager に移動し、ステップ 2 で作成したプロジェクトを選択して、[削除] を選択します。プロジェクトを削除した場合は、Cloud SDK でプロジェクトを変更する必要があります。gcloud projects list を実行すると、使用可能なすべてのプロジェクトのリストを表示できます。