如何在 Cloud Run 執行個體上部署 Hermes 代理程式

1. 簡介

總覽

在本實驗室中,您會將 Hermes Agent (由 Nous Research 提供) 的完整持續性安全執行個體部署至 Cloud Run 執行個體。您將使用 Hermes Web 資訊主頁與 AI 代理程式互動,並使用 Google Cloud Storage 備份持續性工作區。

Hermes 支援閘道模式,可做為自動調整資源配置的 Cloud Run 服務執行,但也會做為有狀態的代理程式,在啟動時掃描技能並處理背景執行作業。Cloud Run 執行個體提供可長期運作的個別定址環境,非常適合這類工作負載。

學習內容

  • 準備 Cloud Storage bucket,用於保存容器狀態和設定。
  • 建立自訂 Python 管理員 (run_hermes.py) 和開機指令碼 (start_hermes.sh),處理開機初始化作業。
  • 使用 gcloud beta run instances deploy 部署 Hermes 代理程式。
  • 存取 Hermes 資訊主頁並進行驗證。

課程內容

  • 如何將 Hermes 代理程式部署至 Cloud Run 執行個體。
  • 如何使用 GCSFuse 將 Cloud Storage 值區掛接至 Cloud Run 執行個體。
  • 如何安全地設定 SQLite 和暫時性快取,略過 GCSFuse 檔案鎖定限制。

2. 設定和需求條件

設定 GCP 專案

  1. 登入 Google Cloud 控制台。
  2. 建立或選取 Google Cloud 專案。
  3. 確認 Google Cloud 專案已啟用計費功能。

開啟 Cloud Shell

從 Cloud Shell 的頂端工具列啟用 Google Cloud Shell。

設定專案並安裝 gcloud beta

首先,將專案和區域設為環境變數。

export PROJECT_ID=<YOUR_PROJECT_ID>
export REGION="us-west2"
export BUCKET_NAME="hermes-state-${PROJECT_ID}"

並為 gcloud 設定專案。

gcloud config set project $PROJECT_ID

確認已為 gcloud beta run instances 安裝 beta 元件:

gcloud components install beta --quiet

gcloud 版本為最新版。

gcloud components updates

啟用必要的 Google Cloud API

在 Cloud Shell 中,啟用 Cloud Run、Cloud Storage 和 Secret Manager API:

gcloud services enable \
  run.googleapis.com \
  secretmanager.googleapis.com \
  storage.googleapis.com \
  compute.googleapis.com \
  aiplatform.googleapis.com

3. 建立專屬服務帳戶

為遵循最小權限原則,請為 Hermes 代理程式建立專屬 IAM 服務帳戶,並授予呼叫 Vertex AI 模型所需的權限:

export SERVICE_ACCOUNT_NAME="hermes-sa"
gcloud iam service-accounts create ${SERVICE_ACCOUNT_NAME} \
  --display-name="Hermes Service Account"

export SERVICE_ACCOUNT="${SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"

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

4. 將憑證儲存在 Secret Manager

我們會將資訊主頁密碼等機密憑證儲存在 Google Cloud Secret Manager,以便 Cloud Run 在啟動時安全地將這些憑證注入容器。

為資訊主頁產生安全的隨機密碼,並儲存在 Secret Manager 中:

export DASHBOARD_PASSWORD=$(openssl rand -hex 16)
echo "Generated Hermes Dashboard Password: ${DASHBOARD_PASSWORD}"

echo -n "${DASHBOARD_PASSWORD}" | gcloud secrets create hermes-dashboard-password \
  --data-file=- \
  --replication-policy="automatic"

gcloud secrets add-iam-policy-binding hermes-dashboard-password \
  --member="serviceAccount:${SERVICE_ACCOUNT}" \
  --role="roles/secretmanager.secretAccessor"

5. 準備 Cloud Storage bucket 和設定檔

Hermes 需要掛接為 /opt/data 的永久儲存空間。我們會使用 Google Cloud Storage (GCS) bucket,並透過 Cloud Storage 磁碟區掛接來掛接該 bucket。

1. 建立 Cloud Storage bucket

gcloud storage buckets create gs://${BUCKET_NAME} --location=${REGION}

# Grant the service account permissions to mount the bucket
gcloud storage buckets add-iam-policy-binding gs://${BUCKET_NAME} \
  --member="serviceAccount:${SERVICE_ACCOUNT}" \
  --role="roles/storage.objectAdmin"

2. 建立「config.yaml」

建立 config.yaml 檔案。請務必加入 _config_version: 12,確保設定正確載入:

_config_version: 12

model:
  default: "google/gemini-3.8-flash"
  provider: "vertex"

dashboard:
  enabled: true

database:
  journal_mode: delete

3. 建立監督員指令碼 (run_hermes.py)

Cloud Storage 不支援 SQLite 資料庫安全執行所需的特定檔案鎖定機制。為避免資料庫損毀,我們需要自訂「監管員」指令碼 (run_hermes.py)。這個指令碼會設定 Hermes,在啟動代理程式前,將暫時資料庫鎖定儲存在容器的本機記憶體中,而非 Cloud Storage。

在本機建立 run_hermes.py:

import os
import shutil
import subprocess
import sys
import threading
import time

print(
    "=== INITIALIZING HERMES SUPERVISOR ===", flush=True
)

# 1. Local Directory Setup
# Creates temporary, local folders (in /tmp) for the agent's caches and working directories.
# See more below in comment section NOTE ON CLOUD STORAGE FUSE
home_dir = "/tmp/hermes_home"
hermes_dir = os.path.join(home_dir, ".hermes")
os.makedirs(hermes_dir, exist_ok=True)
os.makedirs("/tmp/logs", exist_ok=True)
os.makedirs("/tmp/skills", exist_ok=True)
os.makedirs("/tmp/uv_cache", exist_ok=True)
os.makedirs("/tmp/cache", exist_ok=True)
os.makedirs("/opt/data/workspace", exist_ok=True)
os.makedirs("/opt/data/.hermes", exist_ok=True)

# 2. State Restoration & Database Config
# Copies your existing configurations and chat history (state.db) from Cloud Storage into the local folders. 
# It also forces the SQLite database into TRUNCATE mode, a crucial step to prevent database corruption 
# when eventually saving back to Cloud Storage. See more in section 3. Note on Cloud Storage Fuse below
if os.path.exists("/opt/data/config.yaml"):
  shutil.copy("/opt/data/config.yaml", os.path.join(hermes_dir, "config.yaml"))
  print(f"Synced config.yaml -> {hermes_dir}/config.yaml", flush=True)
elif os.path.exists("/opt/data/.hermes/config.yaml"):
  shutil.copy("/opt/data/.hermes/config.yaml", os.path.join(hermes_dir, "config.yaml"))
  print(f"Synced config.yaml from .hermes -> {hermes_dir}/config.yaml", flush=True)

if os.path.exists("/opt/data/.env"):
  shutil.copy("/opt/data/.env", os.path.join(hermes_dir, ".env"))
  print(f"Synced .env -> {hermes_dir}/.env", flush=True)
elif os.path.exists("/opt/data/.hermes/.env"):
  shutil.copy("/opt/data/.hermes/.env", os.path.join(hermes_dir, ".env"))
  print(f"Synced .env from .hermes -> {hermes_dir}/.env", flush=True)

if os.path.exists("/opt/data/.hermes/state.db"):
  shutil.copy("/opt/data/.hermes/state.db", os.path.join(hermes_dir, "state.db"))
  print(f"Synced state.db -> {hermes_dir}/state.db (restored previous chats!)", flush=True)

# 3. Note on Cloud Storage Fuse
# Cloud Storage FUSE is optimized for object storage, but is not fully POSIX compliant.
# This means GCS lacks the byte-range file locking required by active caches and default SQLite (WAL mode) 
# which SQLite depends on to prevent data collisions.
# Without these locks, SQLite experiences database corruption and blocked I/O operations.
# To ensure stability, we route these active I/O processes to local container memory (/tmp).
# See section 5 Enable Autosave below on how /tmp is uploaded to Cloud Storage.
# Read more: https://cloud.google.com/storage/docs/cloud-storage-fuse/overview#differences-and-limitations
db_path = os.path.join(hermes_dir, "state.db")
try:
  import sqlite3
  conn = sqlite3.connect(db_path)
  conn.execute("PRAGMA journal_mode=TRUNCATE;")
  conn.close()
  print("Configured SQLite database to TRUNCATE mode for direct single-file persistence", flush=True)
except Exception as e:
  print(f"Warning: Failed to configure TRUNCATE mode: {e}", flush=True)

subprocess.run(["chmod", "-R", "777", "/tmp"], check=False)

# 4. Update system environment variables
# Hermes needs to know to look at the new local /tmp folders rather than defaulting to the mounted bucket.
env = dict(os.environ)
env["HOME"] = home_dir
env["HERMES_HOME"] = hermes_dir
env["PATH"] = "/opt/hermes/.venv/bin:/opt/hermes/bin:" + env.get("PATH", "")
env["PYTHONUNBUFFERED"] = "1"
env["HERMES_STATE_PATH"] = hermes_dir
env["HERMES_SKILLS_PATH"] = "/tmp/skills"
env["UV_CACHE_DIR"] = "/tmp/uv_cache"
env["XDG_CACHE_HOME"] = "/tmp/cache"
env["SQLITE_BUSY_TIMEOUT"] = "30000"
env["HERMES_ALLOW_ROOT_GATEWAY"] = "1"
env["HERMES_WORKSPACE"] = "/opt/data/workspace"
env["HERMES_WRITE_SAFE_ROOT"] = "/opt/data"

python_bin = "/opt/hermes/.venv/bin/python3"

# 5. Enable Autosave 
# Spawn a background worker thread to watch your local database and config files every 5 seconds.
# As you chat with your agent, this worker thread automatically copies the updated database content
# back to Cloud Storage to persist it.
def sync_to_gcs_loop():
  files_to_sync = ["state.db", "config.yaml", ".env"]
  last_mtimes = {}
  
  # Initialize last_mtimes
  for f in files_to_sync:
    path = os.path.join(hermes_dir, f)
    if os.path.exists(path):
      last_mtimes[f] = os.path.getmtime(path)
    else:
      last_mtimes[f] = 0
      
  while True:
    time.sleep(5)
    for f in files_to_sync:
      src_path = os.path.join(hermes_dir, f)
      if os.path.exists(src_path):
        try:
          mtime = os.path.getmtime(src_path)
          if mtime > last_mtimes.get(f, 0):
            dst_path = os.path.join("/opt/data/.hermes", f)
            shutil.copy2(src_path, dst_path)
            last_mtimes[f] = mtime
            print(f"Auto-saved {f} to GCS volume mount", flush=True)
        except Exception as e:
          print(f"Error auto-saving {f} to GCS: {e}", flush=True)

threading.Thread(target=sync_to_gcs_loop, daemon=True).start()


# 6. Launch the Hermes Gateway (the AI backend) and the Web Dashboard (the UI)
# These are launched as parallel processes, sending logs to Cloud Run via stdout & stderr
print("=== STARTING GATEWAY IN BACKGROUND ===", flush=True)
gw = subprocess.Popen(
    [python_bin, "-m", "hermes_cli.main", "gateway", "run"],
    env=env,
    cwd="/opt/data/workspace",
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
    bufsize=1,
)

def stream_gw():
  for line in iter(gw.stdout.readline, ""):
    if line:
      print(f"[GATEWAY] {line.rstrip()}", flush=True)

threading.Thread(target=stream_gw, daemon=True).start()

print("=== STARTING DASHBOARD ON 0.0.0.0:8080 ===", flush=True)
sys.stdout.flush()

dash = subprocess.Popen(
    [
        python_bin,
        "-m",
        "hermes_cli.main",
        "dashboard",
        "--host",
        "0.0.0.0",
        "--port",
        "8080",
        "--skip-build",
    ],
    env=env,
    cwd="/opt/data/workspace",
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
    bufsize=1,
)

for line in iter(dash.stdout.readline, ""):
  if line:
    print(f"[DASHBOARD] {line.rstrip()}", flush=True)

rc = dash.wait()
print(f"DASHBOARD EXITED WITH RETURN CODE: {rc}", flush=True)

while True:
  time.sleep(10)

4. 建立啟動指令碼 (start_hermes.sh)

在本機建立 start_hermes.sh。

#!/bin/sh
set -e
export PYTHONUNBUFFERED=1
exec python3 /opt/data/run_hermes.py

5. 將檔案上傳至 Cloud Storage

將設定檔複製到 GCS bucket 的根目錄:

gcloud storage cp config.yaml run_hermes.py start_hermes.sh gs://${BUCKET_NAME}/

6. 在 Cloud Run 執行個體上部署 Hermes

我們使用 gcloud beta run instances deploy 部署容器。這個指令包含特定設定,可解決 GCSFuse 和容器限制的已知問題。

請確保環境變數 (PROJECT_ID、REGION、BUCKET_NAME、SERVICE_ACCOUNT) 已匯出至現行終端機工作階段。

部署執行個體:

gcloud beta run instances deploy hermes-instance \
  --image nousresearch/hermes-agent:latest \
  --service-account ${SERVICE_ACCOUNT} \
  --command "/bin/sh" \
  --args "/opt/data/start_hermes.sh" \
  --port 8080 \
  --cpu 2 \
  --memory 4Gi \
  --ingress all \
  --no-invoker-iam-check \
  --add-volume name=hermes-storage,mount-path=/opt/data,type=cloud-storage,mount-options="uid=2000;gid=2000;file-mode=0777;dir-mode=0777;implicit-dirs",bucket=$BUCKET_NAME \
  --set-secrets "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=hermes-dashboard-password:latest" \
  --set-env-vars "PYTHONUNBUFFERED=1,VERTEX_PROJECT_ID=$PROJECT_ID,VERTEX_LOCATION=global,HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin,HERMES_ALLOW_ROOT_GATEWAY=1,HERMES_WORKSPACE=/opt/data/workspace,HERMES_WRITE_SAFE_ROOT=/opt/data" \
  --region $REGION \
  --project $PROJECT_ID

上述包含的重要設定:

  • --service-account:附加專用 hermes-sa 服務帳戶。
  • 監督程式指令碼:start_hermes.sh呼叫自訂 Python 監督程式 run_hermes.py,將 SQLite 鎖定限制和快取問題從 GCS FUSE 轉移到本機 tmpfs。
  • --set-secrets:直接從 Secret Manager 將憑證注入環境變數。

7. 透過 Hermes 網頁版 UI 直接互動

部署完成後,您就能透過產生的 .run.app 網址存取資訊主頁。系統提示您進行驗證時,請輸入 admin 做為使用者名稱,並輸入 ${DASHBOARD_PASSWORD} 做為密碼。

與虛擬服務專員對話

您可以嘗試執行 echo "hello" 等動作,確認代理程式是否正常運作。

測試永久儲存空間

您可以詢問代理程式,測試 Google Cloud Bucket 中的永久儲存空間

Write "hello world" to a file named hello.txt in your workspace.

接著在殼層中執行下列指令,即可驗證檔案是否已寫入

gcloud storage cat gs://$BUCKET_NAME/workspace/hello.txt

最後,如要確認聊天和檔案是否會保留到新的 Cloud Run 執行個體 (Cloud Run 執行個體最多可連續執行 7 天,且預設會自動重新啟動),請完全按照先前的做法重新執行 gcloud beta run instances deploy 指令。然後就會看到對話工作階段。你可以要求代理人

Read the contents of the file hello.txt in your workspace.

並看到「hello world」。

8. 清除

如要避免系統向您的 Google Cloud 帳戶收取本程式碼研究室所用資源的費用,請按照下列步驟操作:

  1. 刪除 Cloud Run 執行個體:
    gcloud beta run instances delete hermes-instance --region ${REGION} --quiet
    
  2. 刪除 Secret Manager Secret:
    gcloud secrets delete hermes-dashboard-password --quiet
    
  3. 刪除 Cloud Storage bucket:
    gcloud storage rm -r gs://${BUCKET_NAME}
    
  4. 刪除專屬服務帳戶:
    gcloud iam service-accounts delete ${SERVICE_ACCOUNT} --quiet
    

9. 結語

恭喜!您已成功在 Cloud Run 執行個體上部署 Hermes Agent 的安全全持續性執行個體,並由 Cloud Storage 提供支援!

您學到的內容

  • 如何將 Hermes 代理程式部署至 Cloud Run 執行個體。
  • 如何使用 GCSFuse 將 Cloud Storage 值區掛接至 Cloud Run 執行個體。
  • 如何安全地設定 SQLite 和暫時性快取,略過 GCSFuse 檔案鎖定限制。