Cloud Run に Gemini を活用したチャットアプリをデプロイする方法

1. はじめに

概要

この Codelab では、Vertex AI Gemini API と Vertex AI クライアント ライブラリを使用して、ノードで記述された基本的な chat bot を作成する方法について説明します。このアプリは、Google Cloud Firestore を基盤とする Express セッション ストアを使用しています。

学習内容

  • htmx、tailwindcss、express.js を使用して Cloud Run サービスを構築する方法
  • Vertex AI クライアント ライブラリを使用して Google API に対する認証を行う方法
  • Gemini モデルとやり取りする chatbot の作成方法
  • Docker ファイルを使用せずに Cloud Run サービスにデプロイする方法
  • Google Cloud Firestore を利用したエクスプレス セッション ストアの使用方法

2. 設定と要件

前提条件

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 を有効にする必要があります。この Codelab では、次の API を使用する必要があります。これらの API を有効にするには、次のコマンドを実行します。

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    aiplatform.googleapis.com \
    secretmanager.googleapis.com

環境変数を設定する

この Codelab 全体で使用する環境変数を設定できます。

PROJECT_ID=<YOUR_PROJECT_ID>
REGION=<YOUR_REGION, e.g. us-central1>
SERVICE=chat-with-gemini
SERVICE_ACCOUNT="vertex-ai-caller"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com
SECRET_ID="SESSION_SECRET"

4. 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. デフォルトの [本番環境モードで開始] を使用し、[作成] をクリックします。

5. サービス アカウントを作成する

このサービス アカウントは、Cloud Run が Vertex AI Gemini API を呼び出すために使用します。このサービス アカウントには、Firestore に対する読み取り / 書き込み権限と、Secret Manager からのシークレットの読み取り権限も付与されます。

まず、次のコマンドを実行してサービス アカウントを作成します。

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run to access Vertex AI APIs"

次に、サービス アカウントに Vertex AI ユーザーロールを付与します。

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/aiplatform.user

次に、Secret Manager でシークレットを作成します。Cloud Run サービスは、このシークレットに環境変数としてアクセスします。この環境変数はインスタンスの起動時に解決されます。シークレットと Cloud Run の詳細をご確認ください。

gcloud secrets create $SECRET_ID --replication-policy="automatic"
printf "keyboard-cat" | gcloud secrets versions add $SECRET_ID --data-file=-

サービス アカウントに、Secret Manager のエクスプレス セッション シークレットへのアクセス権を付与します。

gcloud secrets add-iam-policy-binding $SECRET_ID \
    --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
    --role='roles/secretmanager.secretAccessor'

最後に、Firestore に対する読み取り / 書き込みアクセス権をサービス アカウントに付与します。

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

6. Cloud Run サービスを作成する

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

mkdir chat-with-gemini && cd chat-with-gemini

次に、次の内容の package.json ファイルを作成します。

{
  "name": "chat-with-gemini",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "cssdev": "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/connect-firestore": "^3.0.0",
    "@google-cloud/firestore": "^7.5.0",
    "@google-cloud/vertexai": "^0.4.0",
    "axios": "^1.6.8",
    "express": "^4.18.2",
    "express-session": "^1.18.0",
    "express-ws": "^5.0.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 fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

// cloud run retrieves secret at instance startup time
const secret = process.env.SESSION_SECRET;

const { Firestore } = require("@google-cloud/firestore");
const { FirestoreStore } = require("@google-cloud/connect-firestore");
var session = require("express-session");
app.set("trust proxy", 1); // trust first proxy
app.use(
    session({
        store: new FirestoreStore({
            dataset: new Firestore(),
            kind: "express-sessions"
        }),
        secret: secret,
        /* set secure to false for local dev session history testing */
        /* see more at https://expressjs.com/en/resources/middleware/session.html */
        cookie: { secure: true },
        resave: false,
        saveUninitialized: true
    })
);

const expressWs = require("express-ws")(app);

app.use(express.static("public"));

// Vertex AI Section
const { VertexAI } = require("@google-cloud/vertexai");

// instance of Vertex model
let generativeModel;

// on startup
const port = parseInt(process.env.PORT) || 8080;
app.listen(port, async () => {
    console.log(`demo1: listening on port ${port}`);

    // get project and location from metadata service
    const metadataService = require("./metadataService.js");

    const project = await metadataService.getProjectId();
    const location = await metadataService.getRegion();

    // Vertex client library instance
    const vertex_ai = new VertexAI({
        project: project,
        location: location
    });

    // Instantiate models
    generativeModel = vertex_ai.getGenerativeModel({
        model: "gemini-1.0-pro-001"
    });
});

app.ws("/sendMessage", async function (ws, req) {
    if (!req.session.chathistory || req.session.chathistory.length == 0) {
        req.session.chathistory = [];
    }

    let chatWithModel = generativeModel.startChat({
        history: req.session.chathistory
    });

    ws.on("message", async function (message) {

        console.log("req.sessionID: ", req.sessionID);
        // get session id

        let questionToAsk = JSON.parse(message).message;
        console.log("WebSocket message: " + questionToAsk);

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id="questionToAsk"
                        class="text-black m-2 text-right border p-2 rounded-lg ml-24">
                        ${questionToAsk}
                    </div></div>`);

        // to simulate a natural pause in conversation
        await sleep(500);

        // get timestamp for div to replace
        const now = "fromGemini" + Date.now();

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id=${now}
                        class=" text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${spinnerSvg} 
                    </div></div>`);

        const results = await chatWithModel.sendMessage(questionToAsk);
        const answer =
            results.response.candidates[0].content.parts[0].text;

        ws.send(`<div
                        id=${now}
                        hx-swap-oob="true"
                        hx-swap="outerHTML"
                        class="text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${answer}
                    </div>`);

                    // save to current chat history
        let userHistory = {
            role: "user",
            parts: [{ text: questionToAsk }]
        };
        let modelHistory = {
            role: "model",
            parts: [{ text: answer }]
        };

        req.session.chathistory.push(userHistory);
        req.session.chathistory.push(modelHistory);

        // console.log(
        //     "newly saved chat history: ",
        //     util.inspect(req.session.chathistory, {
        //         showHidden: false,
        //         depth: null,
        //         colors: true
        //     })
        // );
        req.session.save();
    });

    ws.on("close", () => {
        console.log("WebSocket was closed");
    });
});

function sleep(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

// gracefully close the web sockets
process.on("SIGTERM", () => {
    server.close();
});

tailwindCSS の tailwind.config.js ファイルを作成します。

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

デプロイされた Cloud Run サービスのプロジェクト ID とリージョンを取得するための metadataService.js ファイルを作成します。これらの値は、Vertex AI クライアント ライブラリのインスタンスをインスタンス化するために使用されます。

const your_project_id = "YOUR_PROJECT_ID";
const your_region = "YOUR_REGION";

const axios = require("axios");

module.exports = {
    getProjectId: async () => {
        let project = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/project/project-id",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                project = your_project_id;
            } else {
                // running on Clodu Run. Use project id from metadata service
                project = response.data;
            }
        } catch (ex) {
            // running locally on local terminal
            project = your_project_id;
        }

        return project;
    },

    getRegion: async () => {
        let region = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/instance/region",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                region = your_region;
            } else {
                // running on Clodu Run. Use region from metadata service
                let regionFull = response.data;
                const index = regionFull.lastIndexOf("/");
                region = regionFull.substring(index + 1);
            }
        } catch (ex) {
            // running locally on local terminal
            region = your_region;
        }
        return region;
    }
};

spinnerSvg.js というファイルを作成します。

module.exports.spinnerSvg = `<svg class="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;

次に、新しい 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" />
        <script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>

        <title>Demo 1</title>
    </head>
    <body>
        <div id="herewego" text-center>
            <!-- <div id="replaceme2" hx-swap-oob="true">Hello world</div> -->
            <div
                class="container mx-auto mt-8 text-center max-w-screen-lg"
            >
                <div
                    class="overflow-y-scroll bg-white p-2 border h-[500px] space-y-4 rounded-lg m-auto"
                >
                    <div id="toupdate"></div>
                </div>
                <form
                    hx-trigger="submit, keyup[keyCode==13] from:body"
                    hx-ext="ws"
                    ws-connect="/sendMessage"
                    ws-send=""
                    hx-on="htmx:wsAfterSend: document.getElementById('message').value = ''"
                >
                    <div class="mb-6 mt-6 flex gap-4">
                        <textarea
                            rows="2"
                            type="text"
                            id="message"
                            name="message"
                            class="block grow rounded-lg border p-6 resize-none"
                            required
                        >
Is C# a programming language or a musical note?</textarea
                        >
                        <button
                            type="submit"
                            class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                        >
                            Send
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </body>
</html>

7. サービスをローカルで実行する

まず、Codelab のルート ディレクトリ chat-with-gemini にいることを確認します。

cd .. && pwd

次に、次のコマンドを実行して依存関係をインストールします。

npm install

ローカル実行時の ADC の使用

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

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

オプション 1)ADC にお客様の認証情報を使用する

認証情報を使用する場合は、まず gcloud auth list を実行して、gcloud での認証方法を確認します。次に、ID に Vertex AI ユーザーロールを付与する必要があります。ID にオーナーのロールがある場合、この Vertex AI のユーザーロールはすでに付与されています。権限がない場合は、次のコマンドを実行して、Vertex AI ユーザーロールと Datastore ユーザーロールを自分の ID に付与できます。

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/aiplatform.user

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

ローカル セッション シークレットを作成する

次に、ローカル開発用にローカル セッション シークレットを作成します。

export SESSION_SECRET=local-secret

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

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

npm run dev

ウェブサイトをプレビューするには、[ウェブでプレビュー] ボタンを開き、[ポート 8080 をプレビュー] を選択します

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

8. サービスをデプロイする

まず、次のコマンドを実行してデプロイを開始し、使用するサービス アカウントを指定します。サービス アカウントが指定されていない場合は、デフォルトのコンピューティング サービス アカウントが使用されます。

gcloud run deploy $SERVICE \
 --service-account $SERVICE_ACCOUNT_ADDRESS \
 --source . \
  --region $REGION \
  --allow-unauthenticated \
  --set-secrets="SESSION_SECRET=$(echo $SECRET_ID):1"

「ソースからのデプロイには、ビルドされたコンテナを保存するための Artifact Registry Docker リポジトリが必要です。リージョン [us-central1] に [cloud-run-source-deploy] という名前のリポジトリが作成されます。] を選択して「y」と入力します。] をクリックして同意し、続行してください。

9. サービスをテストする

デプロイしたら、ウェブブラウザでサービス URL を開きます。次に、Gemini に質問します。例:「ギターは練習していますが、ソフトウェア エンジニアでもあります。「C#」と表示されたら、プログラミング言語と音符のどちらを思い浮かべるべきでしょうか?どちらを選択すべきでしょうか?」

10. 完了

これでこの Codelab は完了です。

Cloud RunVertex AI Gemini API のドキュメントを確認することをおすすめします。

学習した内容

  • htmx、tailwindcss、express.js を使用して Cloud Run サービスを構築する方法
  • Vertex AI クライアント ライブラリを使用して Google API に対する認証を行う方法
  • Gemini モデルとやり取りする chat bot の作成方法
  • Docker ファイルを使用せずに Cloud Run サービスにデプロイする方法
  • Google Cloud Firestore を利用したエクスプレス セッション ストアの使用方法

11. クリーンアップ

不注意による料金の発生(たとえば、Cloud Run サービスが誤って無料枠の毎月の Cloud Run 呼び出し割り当てよりも多く呼び出された場合)を回避するには、Cloud Run を削除するか、手順 2 で作成したプロジェクトを削除します。

Cloud Run サービスを削除するには、Cloud Run Cloud コンソール(https://console.cloud.google.com/run)に移動して chat-with-gemini サービスを削除します。また、誤って Gemini を呼び出さないように、vertex-ai-caller サービス アカウントを削除するか、Vertex AI ユーザーロールを取り消すこともできます。

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