如何在 Cloud Run 实例上部署 Hermes 代理

1. 简介

概览

在本实验中,您将向 Cloud Run 实例部署一个完全持久、安全的 Hermes Agent(由 Nous Research 提供)。您将使用 Hermes Web 信息中心与 AI 智能体互动,并使用 Google Cloud Storage 为其持久性工作区提供支持。

虽然 Hermes 支持可作为自动扩缩 Cloud Run 服务的网关模式,但它也充当有状态代理,可在启动时扫描技能并处理后台执行。Cloud Run 实例提供了一个可单独寻址的长期运行环境,非常适合此工作负载。

您将执行的操作

  • 准备一个 Cloud Storage 存储桶,用于持久保留容器状态和配置。
  • 创建自定义 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 控制台的顶部工具栏中激活 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 存储桶和配置文件

Hermes 需要装载为 /opt/data 的永久性存储空间。我们将使用 Google Cloud Storage (GCS) 存储桶,并使用 Cloud Storage 卷装载来装载该存储桶。

1. 创建 Cloud Storage 存储桶

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 存储桶的根目录:

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 网页界面直接互动

部署完成后,您便可以使用生成的 .run.app 网址访问信息中心。当系统提示进行身份验证时,请输入 admin 作为用户名,并输入您的 ${DASHBOARD_PASSWORD} 作为密码。

与您的代理聊天

您可以尝试运行 echo "hello" 等命令来确认代理是否正常运行。

测试永久性存储空间

您可以通过向代理提出问题来测试 Google Cloud 存储桶中的持久性存储

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

然后,在 shell 中,您可以运行以下命令来验证文件是否已写入

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. 清理

为避免因本 Codelab 中使用的资源导致您的 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 存储桶:
    gcloud storage rm -r gs://${BUCKET_NAME}
    
  4. 删除专用服务账号:
    gcloud iam service-accounts delete ${SERVICE_ACCOUNT} --quiet
    

9. 总结

恭喜!您已成功在由 Cloud Storage 提供支持的 Cloud Run 实例上部署了安全且完全持久的 Hermes 代理!

要点回顾

  • 如何将 Hermes 代理部署到 Cloud Run 实例。
  • 如何使用 GCSFuse 将 Cloud Storage 存储桶装载到 Cloud Run 实例。
  • 如何安全地配置 SQLite 和临时缓存,以绕过 GCSFuse 文件锁定限制。