Cloud Build를 사용하여 GitHub에서 Cloud Run으로 변경사항을 자동으로 배포하는 방법

1. 소개

개요

이 Codelab에서는 소스 코드 변경사항을 GitHub 저장소에 푸시할 때마다 애플리케이션의 새 버전을 자동으로 빌드하고 배포하도록 Cloud Run을 구성합니다.

이 데모 애플리케이션은 Firestore에 사용자 데이터를 저장하지만 데이터의 일부만 올바르게 저장됩니다. GitHub 저장소에 버그 수정을 푸시하면 새 버전에서 수정 사항을 자동으로 확인할 수 있도록 지속적 배포를 구성합니다.

학습할 내용

  • Cloud Shell 편집기를 사용하여 Express 웹 애플리케이션 작성
  • 지속적 배포를 위해 GitHub 계정을 Google Cloud에 연결
  • Cloud Run에 애플리케이션 자동 배포
  • HTMX 및 TailwindCSS 사용 방법 알아보기

2. 설정 및 요구사항

기본 요건

  • GitHub 계정이 있고 코드를 만들어 저장소로 푸시하는 데 익숙합니다.
  • Cloud 콘솔에 로그인했습니다.
  • 이전에 Cloud Run 서비스를 배포했습니다. 예를 들어 소스 코드에서 웹 서비스 배포 빠른 시작의 안내에 따라 시작할 수 있습니다.

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를 사용해야 합니다. 다음 명령어를 실행하여 이러한 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 Console에서 프로젝트 추가를 클릭합니다.
  2. <YOUR_PROJECT_ID>를 입력합니다. 을 사용하여 기존 Google Cloud 프로젝트 중 하나에 Firebase를 추가합니다.
  3. 메시지가 표시되면 Firebase 약관을 검토하고 이에 동의합니다.
  4. 계속을 클릭합니다.
  5. 요금제 확인을 클릭하여 Firebase 요금제를 확인합니다.
  6. 이 Codelab에서 Google 애널리틱스를 사용 설정하는 것은 선택사항입니다.
  7. Firebase 추가를 클릭합니다.
  8. 프로젝트가 생성되면 계속을 클릭합니다.
  9. 빌드 메뉴에서 Firestore 데이터베이스를 클릭합니다.
  10. 데이터베이스 만들기를 클릭합니다.
  11. 위치 드롭다운에서 리전을 선택하고 다음을 클릭합니다.
  12. 기본값인 프로덕션 모드에서 시작을 사용한 후 만들기를 클릭합니다.

6. 애플리케이션 작성

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

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 사용자 역할이 있거나 (Cloud Shell에서 실행 중인 경우 등 인증에 ID를 사용하는 경우) 이전에 만든 사용자 계정을 가장할 수 있습니다.

로컬에서 실행 시 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에서 미리보기 버튼

이름과 도시 입력란에 입력할 텍스트를 입력하고 저장을 누릅니다. 그런 다음 페이지를 새로고침합니다. 마을 필드가 유지되지 않았음을 알 수 있습니다. 다음 섹션에서 이 버그를 수정합니다.

익스프레스 앱의 로컬 실행을 중지합니다 (예: 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 저장소 안내

다음 명령어를 실행하여 명령줄에서 기존 저장소 푸시 안내에 따릅니다.

먼저 다음을 실행하여 원격 저장소를 추가합니다.

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

기본 브랜치를 업스트림 저장소로 푸시합니다.

git push -u origin main

9. 지속적 배포 설정

이제 GitHub에 코드가 있으므로 지속적 배포를 설정할 수 있습니다. Cloud Run용 Cloud 콘솔로 이동합니다.

  • '서비스 만들기'를 클릭합니다.
  • 저장소에서 지속적 배포를 클릭합니다.
  • Cloud Build 설정을 클릭합니다.
  • 소스 저장소
      아래
    • 저장소 제공업체로 GitHub 선택
    • 연결된 저장소 관리를 클릭하여 Cloud Build의 저장소 액세스를 구성합니다.
    • 저장소를 선택하고 다음을 클릭합니다.
  • 빌드 구성
      에서
    • 브랜치를 ^main$로 유지
    • 빌드 유형으로 Google Cloud 빌드팩을 통해 Go, Node.js, Python, Java, .NET Core, Ruby 또는 PHP를 선택합니다.
  • 빌드 컨텍스트 디렉터리를 /로 둡니다.
  • 저장을 클릭합니다.
  • 인증
      에서
    • 인증되지 않은 호출 허용을 클릭합니다.
  • 컨테이너, 볼륨, 네트워킹, 보안에서
    • 보안 탭에서 이전 단계에서 만든 서비스 계정을 선택합니다(예: 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
    });

to

//fixed town bug
    await doc.set({
        name: name,
        town: town
    });

다음을 실행하여 수정 확인

npm run start

웹 브라우저를 엽니다 데이터를 다시 저장하고 새로고침하세요. 새로고침해도 새로 입력된 도시 데이터가 올바르게 유지되는 것을 볼 수 있습니다.

이제 수정사항을 확인했으므로 배포할 준비가 되었습니다. 먼저 수정사항을 커밋합니다.

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

GitHub의 업스트림 저장소로 푸시합니다.

git push origin main

Cloud Build가 변경사항을 자동으로 배포합니다. Cloud Run 서비스의 Cloud 콘솔로 이동하여 배포 변경사항을 모니터링할 수 있습니다.

프로덕션에서 수정 확인

Cloud Run 서비스의 Cloud 콘솔에 이제 두 번째 버전이 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 서비스를 삭제하려면 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를 실행하면 사용 가능한 모든 프로젝트의 목록을 볼 수 있습니다.