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

1. はじめに

概要

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

学習内容

  • htmx、tailwindcss、express.js を使用して Cloud Run サービスを構築する方法
  • Vertex AI クライアント ライブラリを使用して Google APIs に対して認証する方法
  • Gemini モデルとやり取りする chatbot を作成する方法
  • docker ファイルなしで Cloud Run サービスにデプロイする方法
  • Google Cloud Firestore を基盤とする Express セッション ストアの使用方法

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 を有効にする必要があります。この 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. [Build] メニューから、[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 の Express セッション シークレットへのアクセス権をサービス アカウントに付与します。

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. サービスをローカルで実行する

まず、コードラボのルート ディレクトリ 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 ユーザーロールがすでに付与されています。そうでない場合は、次のコマンドを実行して、ID に Vertex AI ユーザーロールと Datastore ユーザーロールを付与します。

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"

「Deploying from source requires an Artifact Registry Docker repository to store built containers. リージョン [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 APIs に対して認証する方法
  • Gemini モデルとやり取りするチャットボットを作成する方法
  • docker ファイルなしで Cloud Run サービスにデプロイする方法
  • Google Cloud Firestore を基盤とする Express セッション ストアの使用方法

11. クリーンアップ

誤って課金されないようにするには(たとえば、Cloud Run サービスが誤って 無料枠の Cloud Run 呼び出しの月間割り当てよりも多く呼び出された場合など)、Cloud Run を削除するか、ステップ 2 で作成したプロジェクトを削除します。

Cloud Run サービスを削除するには、https://console.cloud.google.com/run で Cloud Run Cloud Console に移動し、chat-with-gemini サービスを削除します。Gemini への意図しない呼び出しを避けるため、vertex-ai-caller サービス アカウントを削除するか、Vertex AI ユーザーロールを取り消すこともできます。

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