Cloud Run에 Gemini 기반 채팅 앱을 배포하는 방법

1. 소개

개요

이 Codelab에서는 Vertex AI Gemini API와 Vertex AI 클라이언트 라이브러리를 사용하여 노드로 작성된 기본 채팅 봇을 만드는 방법을 알아봅니다. 이 앱은 Google Cloud Firestore에서 지원하는 Express 세션 저장소를 사용합니다.

학습할 내용

  • htmx, tailwindcss, express.js를 사용해 Cloud Run 서비스를 빌드하는 방법
  • Vertex AI 클라이언트 라이브러리를 사용하여 Google API에 인증하는 방법
  • 챗봇을 만들어 Gemini 모델과 상호작용하는 방법
  • 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

가상 머신에는 필요한 개발 도구가 모두 들어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 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 Console에서 프로젝트 추가를 클릭합니다.
  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 서비스 만들기

먼저 소스 코드용 디렉터리를 만들고 해당 디렉터리로 cd하세요.

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 사용자 역할이 이미 있는 것입니다. 그렇지 않은 경우 이 명령어를 실행하여 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"

'소스에서 배포하려면 빌드된 컨테이너를 저장할 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 모델과 상호작용하는 방법
  • 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를 실행하면 사용 가능한 모든 프로젝트의 목록을 볼 수 있습니다.