星期日, 11月 23, 2025

Cloud Run Job 執行小記

 Cloud Run Job 執行小記



OS: macOS 14.3

Google Cloud SDK: 539.0.0

Gemini CLI: 0.17.1


今天要來寫 Cloud Run Job 執行小記


在使用 Cloud Run 服務的時候, 會有兩種類型的執行方式, 分別是

  • Cloud Run Service

  • Cloud Run Job


這邊使用 NotebookLM 參考官方文件, 先將 Service 與 Job 大概區分出來, 如以下圖示

  • 這邊不得不說, Nano Banana Pro 的繪圖功力真是一絕 :) 


以自己Infra的角度來說, Cloud Run 如果是自己的應用面, 可能有兩個切入點

  • Cloud Run Service: 將要執行的工作建立成服務, 然後藉由不同的傳入值與呼叫不同的 API 達成目的.

  • Cloud Run Job: 將要執行的工作封裝為容器, 手動執行或是編為排程.


依照我個人來說, Cloud Run Job 比較適合我的維運場景, 故今天來寫這篇 Cloud Run Job 執行小記, 執行想法如下


目的: 使用 Google Cloud Recommender API 抓取虛擬機 (VM) 的大小調整建議

  • 使用 Gemini CLI 產生 Python 檔案

  • 使用 Gemini CLI - 將剛剛建立的 Python 檔案封裝至容器

  • 使用 Gemini CLI - 建立 Shell script 執行 Cloud Run Job 部署

  • 使用 Gemini CLI - 建立說明檔


首先建立 python 檔案如下


==== config.json 檔案 ====


{

  "projects": [

    "your-project-id-1"

  ],

  "locations": [

    "asia-east1-c",

    "us-central1",

    "europe-west1-b"

  ],

  "gcs_bucket_name_for_table": "your-bucket-for-text-reports",

  "gcs_bucket_name_for_json": "your-bucket-for-json-data",

  "gcs_bucket_project": "your-gcs-bucket-project-id",

  "gcs_bucket_location": "asia-east1"

}


==== pyproject.toml 檔案 ==== 


[project]

name = "temp"

version = "0.1.0"

description = "Add your description here"

readme = "README.md"

requires-python = ">=3.12"

dependencies = [

    "google-cloud-recommender>=2.18.2",

    "google-cloud-storage>=3.4.1",

]


==== recommend.py 檔案 ==== 


import json

from datetime import datetime

from google.cloud import recommender_v1, storage


# --- 設定區塊 --- #

# 載入設定檔 config.json

def load_config():

    """從 config.json 載入設定參數"""

    try:

        with open("config.json", "r") as f:

            config = json.load(f)

        # 回傳從 config.json 讀取到的各項設定

        return (

            config["projects"],  # GCP 專案 ID 列表

            config["locations"], # GCP 地區列表

            config.get("gcs_bucket_name_for_table"), # 用於儲存文字報告的 GCS 儲存桶名稱

            config.get("gcs_bucket_name_for_json"),  # 用於儲存 JSON 報告的 GCS 儲存桶名稱

            config.get("gcs_bucket_project"),      # 建立 GCS 儲存桶的專案 ID

            config.get("gcs_bucket_location"),     # 建立 GCS 儲存桶的區域

        )

    except FileNotFoundError:

        print("錯誤:找不到 config.json 檔案。請確認檔案是否存在。")

        exit(1)

    except KeyError:

        print("錯誤:config.json 檔案格式不正確,缺少必要的鍵。請檢查檔案內容。")

        exit(1)


# 載入設定並賦值給全域變數

PROJECTS, LOCATIONS, GCS_BUCKET_TABLE, GCS_BUCKET_JSON, GCS_BUCKET_PROJECT, GCS_BUCKET_LOCATION = load_config()

RECOMMENDER_ID = "google.compute.instance.MachineTypeRecommender" # Recommender ID,用於取得 VM 大小調整建議

# --- 設定區塊結束 --- #


def generate_table_output(recommendations):

    """產生人類可讀的表格格式報告 (字串)


    Args:

        recommendations (list): 包含推薦資料的列表。


    Returns:

        str: 格式化的表格報告字串。

    """

    if not recommendations:

        return "找不到任何推薦。"


    lines = []

    # 動態計算欄寬,讓 summary 可以完整顯示

    project_w = max([len(r['project']) for r in recommendations] + [len('Project')])

    location_w = max([len(r['location']) for r in recommendations] + [len('Location')])

    vm_name_w = max([len(r['vm_name']) for r in recommendations] + [len('VM Name')])

    savings_w = 15 # 固定寬度,用於顯示節省金額

    summary_w = max([len(r['summary']) for r in recommendations] + [len('Summary')])


    # 表格標頭

    header = f"| {'Project':<{project_w}} | {'Location':<{location_w}} | {'VM Name':<{vm_name_w}} | {'Savings (USD)':>{savings_w}} | {'Summary':<{summary_w}} |"

    separator = '-' * len(header)


    lines.append(separator)

    lines.append(header)

    lines.append(separator)


    # 逐行添加推薦資料

    for rec in recommendations:

        row = f"| {rec['project']:<{project_w}} | {rec['location']:<{location_w}} | {rec['vm_name']:<{vm_name_w}} | {rec['monthly_savings_usd']:>{savings_w}.2f} | {rec['summary']:<{summary_w}} |"

        lines.append(row)


    lines.append(separator)

    return "\n".join(lines)


def upload_to_gcs(storage_client, bucket_name, filename, content, content_type):

    """上傳字串內容到指定的 GCS 儲存桶


    Args:

        storage_client (google.cloud.storage.Client): GCS 客戶端實例。

        bucket_name (str): 目標儲存桶的名稱。

        filename (str): 上傳到 GCS 後的檔案名稱。

        content (str): 要上傳的字串內容。

        content_type (str): 內容類型 (例如 'text/plain', 'application/json')。

    """

    try:

        bucket = storage_client.bucket(bucket_name)

        blob = bucket.blob(filename)

        blob.upload_from_string(content, content_type=content_type)

        print(f"成功!檔案已上傳到 gs://{bucket_name}/{filename}")

    except Exception as e:

        print(f"上傳到 gs://{bucket_name} 失敗: {e}")

        print("請確認儲存桶名稱是否正確,以及您是否有寫入權限。")


def create_bucket_if_not_exists(storage_client, bucket_name, location, project_id):

    """檢查儲存桶是否存在,若不存在則嘗試建立。


    Args:

        storage_client (google.cloud.storage.Client): GCS 客戶端實例。

        bucket_name (str): 要檢查或建立的儲存桶名稱。

        location (str): 儲存桶的地理位置 (region)。

        project_id (str): 儲存桶所屬的專案 ID。


    Returns:

        google.cloud.storage.Bucket: 儲存桶實例,如果建立失敗則為 None。

    """

    try:

        bucket = storage_client.lookup_bucket(bucket_name)

        if bucket is None:

            print(f"儲存桶 gs://{bucket_name} 不存在,正在嘗試於 {location} 建立...")

            # 嘗試在指定的專案和位置建立儲存桶

            new_bucket = storage_client.create_bucket(bucket_name, project=project_id, location=location)

            print(f"成功建立儲存桶 gs://{new_bucket.name}。")

            return new_bucket

        return bucket

    except Exception as e:

        print(f"建立或檢查儲存桶 gs://{bucket_name} 時發生錯誤: {e}")

        print("請確認您有足夠的權限 (例如 roles/storage.admin) 來建立儲存桶。")

        return None


def fetch_and_process_recommendations():

    """抓取推薦、處理並上傳到 GCS。


    此函數會執行以下步驟:

    1. 初始化 Recommender 客戶端。

    2. 遍歷 config.json 中定義的每個專案和地區,獲取 VM 大小調整建議。

    3. 從推薦結果中提取 VM 名稱、摘要、預估每月節省金額等資訊。

    4. 將所有推薦資訊彙整成列表。

    5. 如果有推薦,則生成表格格式和 JSON 格式的報告。

    6. 將表格報告印在終端機上供預覽。

    7. 使用指定的專案和位置,確保 GCS 儲存桶存在(如果不存在則建立)。

    8. 將兩種格式的報告上傳到 GCS。

    """

    recommender_client = recommender_v1.RecommenderClient() # 初始化 Recommender 客戶端

    all_recommendations = [] # 用於存放所有專案和地區的推薦資訊


    print("開始掃描推薦...")

    for project in PROJECTS:

        for location in LOCATIONS:

            # 構建 Recommender API 的父路徑

            parent = f"projects/{project}/locations/{location}/recommenders/{RECOMMENDER_ID}"

            print(f"正在取得 {parent} 的推薦...")

            try:

                # 列出指定路徑下的所有推薦

                for rec in recommender_client.list_recommendations(parent=parent):

                    instance_name = "N/A"

                    try:

                        # 從推薦內容中提取 VM 實例名稱

                        for op_group in rec.content.operation_groups:

                            for op in op_group.operations:

                                if "/instances/" in op.resource:

                                    instance_name = op.resource.split("/instances/")[-1]

                                    break

                            if instance_name != "N/A": break

                    except (AttributeError, IndexError): pass


                    savings_amount = 0.0

                    try:

                        # 從推薦中提取預估的每月節省金額

                        cost = rec.primary_impact.cost_projection.cost

                        if cost.units or cost.nanos: savings_amount = -1 * (cost.units + cost.nanos / 1e9)

                    except (AttributeError, IndexError): pass


                    # 整理推薦數據

                    recommendation_data = {

                        "vm_name": instance_name,

                        "summary": rec.description,

                        "monthly_savings_usd": round(savings_amount, 2),

                        "project": project,

                        "location": location,

                        "recommendation_name": rec.name,

                    }

                    all_recommendations.append(recommendation_data)


            except Exception as e:

                print(f"無法從 {parent} 取得推薦: {e}")


    if not all_recommendations:

        print("找不到任何推薦,無需上傳。")

        return


    print(f"\n共找到 {len(all_recommendations)} 則推薦。")


    # 1. 產生兩種格式的報告

    table_report_string = generate_table_output(all_recommendations)

    json_report_string = json.dumps(all_recommendations, indent=2, ensure_ascii=False)


    # 2. 將表格格式印在終端機預覽

    print("--- 表格報告 (預覽) ---")

    print(table_report_string)

    print("--- 結束預覽 ---")


    # 3. 上傳兩種格式到 GCS

    # 初始化 GCS 客戶端,並指定專案 ID

    storage_client = storage.Client(project=GCS_BUCKET_PROJECT)

    current_date = datetime.now().strftime('%Y-%m-%d') # 獲取當前日期作為檔案名稱的一部分

    # 設定儲存桶的位置,優先使用 config.json 中的 GCS_BUCKET_LOCATION,否則使用第一個推薦地區,最後預設為 'US'

    bucket_location = GCS_BUCKET_LOCATION if GCS_BUCKET_LOCATION else (LOCATIONS[0] if LOCATIONS else "US")


    # 如果設定了文字報告的 GCS 儲存桶名稱

    if GCS_BUCKET_TABLE:

        # 檢查或建立儲存桶

        if create_bucket_if_not_exists(storage_client, GCS_BUCKET_TABLE, bucket_location, GCS_BUCKET_PROJECT):

            table_filename = f"text-reports/recommendations_{current_date}.txt"

            print(f"\n正在上傳表格報告至 gs://{GCS_BUCKET_TABLE}/{table_filename}...")

            upload_to_gcs(storage_client, GCS_BUCKET_TABLE, table_filename, table_report_string, "text/plain; charset=utf-8")


    # 如果設定了 JSON 報告的 GCS 儲存桶名稱

    if GCS_BUCKET_JSON:

        # 檢查或建立儲存桶

        if create_bucket_if_not_exists(storage_client, GCS_BUCKET_JSON, bucket_location, GCS_BUCKET_PROJECT):

            json_filename = f"json-data/recommendations_{current_date}.json"

            print(f"\n正在上傳 JSON 報告至 gs://{GCS_BUCKET_JSON}/{json_filename}...")

            upload_to_gcs(storage_client, GCS_BUCKET_JSON, json_filename, json_report_string, "application/json")


# 腳本主入口點

if __name__ == "__main__":

    fetch_and_process_recommendations()


建立完成後進入到下一個階段, 請 gemini cli 封裝成容器


==== Dockerfile 檔案 ==== 


# Use an official Python runtime as a parent image

FROM python:3.12-slim


# Set the working directory in the container

WORKDIR /app


# Copy pyproject.toml and install dependencies

# This ensures that dependencies are installed before copying the rest of the application

# which can help with Docker layer caching.

COPY pyproject.toml pyproject.toml


# Install build dependencies and then project dependencies

RUN pip install --no-cache-dir ".[dev]" && \

    pip install --no-cache-dir .


# Copy the rest of the application code

COPY . .


# Set the command to run the application

CMD ["python", "recommend.py"]


接下來請 Gemini CLI 建立一個 deploy.sh 來協助部署


==== deploy.sh 檔案 ==== 


#!/bin/bash


# -----------------------------------------------------------------------------

# GCP Cloud Run Job 部署腳本 - GCP Recommender

# -----------------------------------------------------------------------------

# 這個腳本用於自動化建置 Docker 映像檔、將其推送到 Google Artifact Registry,

# 並部署為 Google Cloud Run Job。此 Job 的主要功能是根據 config.json 的設定,

# 抓取 GCP Recommender 的建議,並將報告上傳到 GCS

#

# 此腳本將使用 GCP 專案的「預設 Compute Engine 服務帳戶」來執行 Job。

#

# 使用前請務必修改「使用者可配置變數」區塊中的值。

# -----------------------------------------------------------------------------



# --- 使用者可配置變數 ---

# -----------------------------------------------------------------------------

# 請根據您的 GCP 環境和需求修改以下變數。

# -----------------------------------------------------------------------------


# GCP_PROJECT_ID:

#   這是您的 GCP 專案 ID。此專案將用於:

#   1. 建置 Docker 映像檔 (透過 Cloud Build)。

#   2. 存放 Docker 映像檔 (在 Artifact Registry 中)。

#   3. 部署 Cloud Run Job。

#   請將 "your-gcp-project-id" 替換為您實際的專案 ID

GCP_PROJECT_ID="your-gcp-project-id"


# GCP_REGION:

#   Cloud Run Job 將被部署到的 GCP 區域。

#   範例: "asia-east1", "us-central1"

GCP_REGION="asia-east1"


# REPO_NAME:

#   用於存放 Docker 映像檔的 Artifact Registry 倉庫名稱。

#   如果倉庫不存在,腳本會嘗試建立它。

REPO_NAME="cloud-run-jobs"


# IMAGE_NAME:

#   您要為 Docker 映像檔指定的名稱。

IMAGE_NAME="gcp-recommender"


# JOB_NAME:

#   您要為 Cloud Run Job 指定的名稱。

JOB_NAME="gcp-recommender-job"

# -----------------------------------------------------------------------------



# --- 內部變數與邏輯 ---

# -----------------------------------------------------------------------------

IMAGE_TAG="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPO_NAME}/${IMAGE_NAME}:latest"

# -----------------------------------------------------------------------------



# --- 步驟 1: 檢查並啟用必要的 GCP APIs ---

# -----------------------------------------------------------------------------

echo ""

echo "\n--- 正在檢查並啟用必要的 GCP APIs... ---"

echo ""


# 需要的 API 列表

REQUIRED_APIS=(

  "cloudbuild.googleapis.com"

  "run.googleapis.com"

  "recommender.googleapis.com"

  "storage.googleapis.com"

  "artifactregistry.googleapis.com"

)


# 獲取已啟用的 API 列表

ENABLED_APIS=$(gcloud services list --enabled --project="${GCP_PROJECT_ID}" --format="value(config.name)")


for API in "${REQUIRED_APIS[@]}"; do

  if [[ ! " ${ENABLED_APIS[@]} " =~ " ${API} " ]]; then

    echo "正在啟用 API: ${API}..."

    gcloud services enable "${API}" --project="${GCP_PROJECT_ID}"

    if [ $? -ne 0 ]; then

      echo "\n❌ 啟用 API '${API}' 失敗。請檢查權限或手動啟用後再試。"

      exit 1

    fi

    echo "✅ API '${API}' 已啟用。"

  else

    echo "✅ API '${API}' 已啟用。"

  fi

done

# -----------------------------------------------------------------------------



# --- 步驟 2: 檢查並建立 Artifact Registry 倉庫 ---

# -----------------------------------------------------------------------------

echo ""

echo "\n--- 正在檢查並建立 Artifact Registry 倉庫... ---"

echo ""

# 檢查倉庫是否存在

if ! gcloud artifacts repositories describe "${REPO_NAME}" --location="${GCP_REGION}" --project="${GCP_PROJECT_ID}" &> /dev/null; then

  echo "倉庫 '${REPO_NAME}' 不存在,正在於區域 '${GCP_REGION}' 中建立..."

  gcloud artifacts repositories create "${REPO_NAME}" \

    --repository-format=docker \

    --location="${GCP_REGION}" \

    --project="${GCP_PROJECT_ID}" \

    --description="Docker repository for Cloud Run Jobs"


  if [ $? -ne 0 ]; then

    echo "\n❌ 建立 Artifact Registry 倉庫 '${REPO_NAME}' 失敗。請檢查權限或手動建立後再試。"

    exit 1

  fi

  echo "✅ 倉庫 '${REPO_NAME}' 已成功建立。"

else

  echo "✅ 倉庫 '${REPO_NAME}' 已存在。"

fi

# -----------------------------------------------------------------------------



# --- 步驟 3: 執行前確認 ---

# -----------------------------------------------------------------------------

echo ""

echo "將要執行以下操作:"

echo "1. 在專案 '${GCP_PROJECT_ID}' 中建置 Docker 映像檔並推送到 Artifact Registry"

echo "   - 映像檔路徑: ${IMAGE_TAG}"

echo ""

echo "2. 部署/更新 Cloud Run Job '${JOB_NAME}'"

echo "   - 部署區域: ${GCP_REGION}"

echo "   - 服務帳戶: 將使用專案預設的 Compute Engine 服務帳戶"

echo "     (請確保此帳戶有足夠的 IAM 權限,詳見 CLOUD_RUN_JOB_DEPLOYMENT.md)"


# 提示使用者確認是否繼續

echo ""

read -p "確定要繼續嗎? (y/n) " -n 1 -r

echo

if [[ ! $REPLY =~ ^[Yy]$ ]]

then

    [[ "$0" = "$BASH_SOURCE" ]] && exit 1 || return 1

fi

# -----------------------------------------------------------------------------



# --- 步驟 4: 使用 Cloud Build 建置並推送映像檔 ---

# -----------------------------------------------------------------------------

echo ""

echo "\n--- 正在建置 Docker 映像檔並推送到 Artifact Registry... ---"

echo ""

gcloud builds submit --tag "${IMAGE_TAG}" . --project="${GCP_PROJECT_ID}"


if [ $? -ne 0 ]; then

  echo "\n❌ 映像檔建置或推送失敗。請檢查錯誤訊息。"

  exit 1

fi

echo "✅ 映像檔建置並推送成功: ${IMAGE_TAG}"

echo ""

# -----------------------------------------------------------------------------



# --- 步驟 5: 部署 Cloud Run Job ---

# -----------------------------------------------------------------------------

echo ""

echo "\n--- 正在部署 Cloud Run Job '${JOB_NAME}'... ---"

echo ""

gcloud run jobs deploy "${JOB_NAME}" \

  --image "${IMAGE_TAG}" \

  --region "${GCP_REGION}" \

  --cpu 1 \

  --memory "512Mi" \

  --max-retries 0 \

  --task-timeout "3600s" \

  --project "${GCP_PROJECT_ID}"


if [ $? -eq 0 ]; then

  echo ""

  echo "\n✅ Cloud Run Job '${JOB_NAME}' 部署成功!"

  echo ""

  echo "您可以執行以下指令來手動觸發 Job 執行:"

  echo "gcloud run jobs execute ${JOB_NAME} --region ${GCP_REGION} --project ${GCP_PROJECT_ID}"

  echo ""

  echo "\n若要查看 Job 的執行結果,請使用以下指令:"

  echo "gcloud logging read 'resource.type=\"cloud_run_job\" AND resource.labels.job_name=\"${JOB_NAME}\"' --project ${GCP_PROJECT_ID} --format 'value(textPayload)' --limit=50"

  echo ""

else

  echo "\n❌ Cloud Run Job '${JOB_NAME}' 部署失敗。請檢查錯誤訊息。"

fi

# -----------------------------------------------------------------------------


 

最後就是執行 deploy.sh 來進行部署了 :) 

  • 中間應該會來來回回跟 gemini cli 修正內容, 但是已經比從頭寫更快很多了



好像又前進一步


~ enjoy it




References