fix: 统一沙盒后端命名与配置说明

This commit is contained in:
Wenjie Zhang 2026-03-29 16:52:33 +08:00
parent 81bf19c9bc
commit e3515fa795
7 changed files with 157 additions and 58 deletions

View File

@ -10,28 +10,30 @@ SANDBOX_MAX_OUTPUT_BYTES=262144
SANDBOX_KEEPALIVE_INTERVAL_SECONDS=30 SANDBOX_KEEPALIVE_INTERVAL_SECONDS=30
SANDBOX_IDLE_TIMEOUT_SECONDS=120 SANDBOX_IDLE_TIMEOUT_SECONDS=120
SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=10 SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=10
# sandbox-provisioner backend: memory | local | docker | kubernetes # sandbox-provisioner backend: memory | docker | kubernetes
SANDBOX_PROVISIONER_BACKEND=local # `local` 仍兼容,但只是 `docker` 的历史别名,不再推荐继续配置
# local/docker backend defaults (deerFlow local_backend style) SANDBOX_PROVISIONER_BACKEND=docker
# sandbox-provisioner 通用配置
# SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
# SANDBOX_CONTAINER_PORT=8080 # SANDBOX_CONTAINER_PORT=8080
# SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal # SANDBOX_HEALTH_TIMEOUT_SECONDS=300
# Docker provisioner options (used when SANDBOX_PROVISIONER_BACKEND=local/docker) # MEMORY_SANDBOX_URL_TEMPLATE=http://agent-sandbox:8000
# SANDBOX_DOCKER_NETWORK=yuxi-know_app-network
# SANDBOX_DOCKER_THREADS_HOST_PATH=
# SANDBOX_DOCKER_SKILLS_HOST_PATH=
# SANDBOX_DOCKER_SANDBOX_PREFIX=yuxi-sandbox
# Optional proxy for sandbox-provisioner container
# SANDBOX_HTTP_PROXY=http://host.docker.internal:7897 # SANDBOX_HTTP_PROXY=http://host.docker.internal:7897
# SANDBOX_HTTPS_PROXY=http://host.docker.internal:7897 # SANDBOX_HTTPS_PROXY=http://host.docker.internal:7897
# K8s provisioner options (used when SANDBOX_PROVISIONER_BACKEND=kubernetes)
# Docker backend 专用 (used when SANDBOX_PROVISIONER_BACKEND=docker/local)
# SANDBOX_DOCKER_NETWORK=yuxi-know_app-network
# SANDBOX_DOCKER_THREADS_HOST_PATH=
# SANDBOX_DOCKER_SANDBOX_PREFIX=yuxi-sandbox
# SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal
# Kubernetes backend 专用 (used when SANDBOX_PROVISIONER_BACKEND=kubernetes)
# SANDBOX_K8S_NAMESPACE=yuxi-know # SANDBOX_K8S_NAMESPACE=yuxi-know
# SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
# SANDBOX_SKILLS_HOST_PATH=/app/saves/skills
# SANDBOX_THREADS_HOST_PATH=/app/saves/threads
# SANDBOX_NODE_HOST=host.docker.internal # SANDBOX_NODE_HOST=host.docker.internal
# KUBECONFIG_PATH=/root/.kube/config # KUBECONFIG_PATH=/root/.kube/config
# Memory backend sandbox url template, supports {sandbox_id} # THREAD_PVC=yuxi-thread
# MEMORY_SANDBOX_URL_TEMPLATE=http://agent-sandbox:8000 # SKILLS_PVC=yuxi-skills # 当前代码会读取,但 Pod 挂载实际仍只使用 THREAD_PVC
# region model_provider # region model_provider
SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ

View File

@ -0,0 +1,56 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
MODULE_NAME = "sandbox_provisioner_app_for_test"
def _find_module_path() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
candidate = parent / "docker" / "sandbox_provisioner" / "app.py"
if candidate.exists():
return candidate
raise FileNotFoundError("docker/sandbox_provisioner/app.py not found from test path")
MODULE_PATH = _find_module_path()
def _load_module():
existing = sys.modules.get(MODULE_NAME)
if existing is not None:
return existing
spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def test_canonical_backend_name_maps_local_to_docker(monkeypatch):
monkeypatch.setenv("PROVISIONER_BACKEND", "memory")
module = _load_module()
assert module.canonical_backend_name("local") == "docker"
assert module.canonical_backend_name("docker") == "docker"
assert module.canonical_backend_name("kubernetes") == "kubernetes"
def test_build_backend_keeps_local_compatible_but_returns_docker_name(monkeypatch):
monkeypatch.setenv("PROVISIONER_BACKEND", "local")
module = _load_module()
sentinel = object()
monkeypatch.setattr(module, "LocalContainerProvisionerBackend", lambda: sentinel)
backend_impl, backend_name = module._build_backend()
assert backend_impl is sentinel
assert backend_name == "docker"

View File

@ -106,23 +106,30 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
- PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-local} # Common provisioner settings
- K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know} - PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-docker}
- SANDBOX_IMAGE=${SANDBOX_IMAGE:-enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest} - SANDBOX_IMAGE=${SANDBOX_IMAGE:-enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest}
- SANDBOX_CONTAINER_PORT=${SANDBOX_CONTAINER_PORT:-8080} - SANDBOX_CONTAINER_PORT=${SANDBOX_CONTAINER_PORT:-8080}
- SKILLS_HOST_PATH=${SANDBOX_SKILLS_HOST_PATH:-/app/saves/skills}
- THREADS_HOST_PATH=${SANDBOX_THREADS_HOST_PATH:-/app/saves/threads}
- NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal}
- KUBECONFIG_PATH=${KUBECONFIG_PATH:-}
- MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000} - MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000}
- DOCKER_NETWORK=${SANDBOX_DOCKER_NETWORK:-yuxi-know_app-network} - SANDBOX_HEALTH_TIMEOUT_SECONDS=${SANDBOX_HEALTH_TIMEOUT_SECONDS:-300}
- DOCKER_THREADS_HOST_PATH=${SANDBOX_DOCKER_THREADS_HOST_PATH:-}
- DOCKER_SKILLS_HOST_PATH=${SANDBOX_DOCKER_SKILLS_HOST_PATH:-}
- DOCKER_SANDBOX_PREFIX=${SANDBOX_DOCKER_SANDBOX_PREFIX:-yuxi-sandbox}
- DOCKER_SANDBOX_HOST=${SANDBOX_DOCKER_SANDBOX_HOST:-host.docker.internal}
- SANDBOX_IDLE_TIMEOUT_SECONDS=${SANDBOX_IDLE_TIMEOUT_SECONDS:-120} - SANDBOX_IDLE_TIMEOUT_SECONDS=${SANDBOX_IDLE_TIMEOUT_SECONDS:-120}
- SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=${SANDBOX_IDLE_CHECK_INTERVAL_SECONDS:-10} - SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=${SANDBOX_IDLE_CHECK_INTERVAL_SECONDS:-10}
- SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180}
# Optional proxy for sandbox-provisioner container
- HTTP_PROXY=${SANDBOX_HTTP_PROXY:-}
- HTTPS_PROXY=${SANDBOX_HTTPS_PROXY:-}
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
# Docker backend only
- DOCKER_NETWORK=${SANDBOX_DOCKER_NETWORK:-yuxi-know_app-network}
- DOCKER_THREADS_HOST_PATH=${SANDBOX_DOCKER_THREADS_HOST_PATH:-}
- DOCKER_SANDBOX_PREFIX=${SANDBOX_DOCKER_SANDBOX_PREFIX:-yuxi-sandbox}
- DOCKER_SANDBOX_HOST=${SANDBOX_DOCKER_SANDBOX_HOST:-host.docker.internal}
# Kubernetes backend only
- K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know}
- NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal}
- KUBECONFIG_PATH=${KUBECONFIG_PATH:-}
- THREAD_PVC=${THREAD_PVC:-yuxi-thread}
- SKILLS_PVC=${SKILLS_PVC:-yuxi-skills}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8002/health').read()"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8002/health').read()"]
interval: 10s interval: 10s

View File

@ -137,26 +137,30 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
- PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-local} # Common provisioner settings
- K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know} - PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-docker}
- SANDBOX_IMAGE=${SANDBOX_IMAGE:-enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest} - SANDBOX_IMAGE=${SANDBOX_IMAGE:-enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest}
- SANDBOX_CONTAINER_PORT=${SANDBOX_CONTAINER_PORT:-8080} - SANDBOX_CONTAINER_PORT=${SANDBOX_CONTAINER_PORT:-8080}
- SKILLS_HOST_PATH=${SANDBOX_SKILLS_HOST_PATH:-/app/saves/skills}
- THREADS_HOST_PATH=${SANDBOX_THREADS_HOST_PATH:-/app/saves/threads}
- NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal}
- KUBECONFIG_PATH=${KUBECONFIG_PATH:-}
- MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000} - MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000}
- HTTP_PROXY=${SANDBOX_HTTP_PROXY:-} - SANDBOX_HEALTH_TIMEOUT_SECONDS=${SANDBOX_HEALTH_TIMEOUT_SECONDS:-300}
- HTTPS_PROXY=${SANDBOX_HTTPS_PROXY:-}
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
- DOCKER_NETWORK=${SANDBOX_DOCKER_NETWORK:-yuxi-know_app-network}
- DOCKER_THREADS_HOST_PATH=${SANDBOX_DOCKER_THREADS_HOST_PATH:-}
- DOCKER_SKILLS_HOST_PATH=${SANDBOX_DOCKER_SKILLS_HOST_PATH:-}
- DOCKER_SANDBOX_PREFIX=${SANDBOX_DOCKER_SANDBOX_PREFIX:-yuxi-sandbox}
- DOCKER_SANDBOX_HOST=${SANDBOX_DOCKER_SANDBOX_HOST:-host.docker.internal}
- SANDBOX_IDLE_TIMEOUT_SECONDS=${SANDBOX_IDLE_TIMEOUT_SECONDS:-120} - SANDBOX_IDLE_TIMEOUT_SECONDS=${SANDBOX_IDLE_TIMEOUT_SECONDS:-120}
- SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=${SANDBOX_IDLE_CHECK_INTERVAL_SECONDS:-10} - SANDBOX_IDLE_CHECK_INTERVAL_SECONDS=${SANDBOX_IDLE_CHECK_INTERVAL_SECONDS:-10}
- SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180}
# Optional proxy for sandbox-provisioner container
- HTTP_PROXY=${SANDBOX_HTTP_PROXY:-}
- HTTPS_PROXY=${SANDBOX_HTTPS_PROXY:-}
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
# Docker backend only
- DOCKER_NETWORK=${SANDBOX_DOCKER_NETWORK:-yuxi-know_app-network}
- DOCKER_THREADS_HOST_PATH=${SANDBOX_DOCKER_THREADS_HOST_PATH:-}
- DOCKER_SANDBOX_PREFIX=${SANDBOX_DOCKER_SANDBOX_PREFIX:-yuxi-sandbox}
- DOCKER_SANDBOX_HOST=${SANDBOX_DOCKER_SANDBOX_HOST:-host.docker.internal}
# Kubernetes backend only
- K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know}
- NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal}
- KUBECONFIG_PATH=${KUBECONFIG_PATH:-}
- THREAD_PVC=${THREAD_PVC:-yuxi-thread}
- SKILLS_PVC=${SKILLS_PVC:-yuxi-skills}
command: > command: >
sh -lc "python -c 'import docker' >/dev/null 2>&1 || pip install --no-cache-dir 'docker>=7.1.0'; sh -lc "python -c 'import docker' >/dev/null 2>&1 || pip install --no-cache-dir 'docker>=7.1.0';
uvicorn app:app --host 0.0.0.0 --port 8002" uvicorn app:app --host 0.0.0.0 --port 8002"

View File

@ -16,6 +16,13 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def canonical_backend_name(backend: str) -> str:
value = (backend or "").strip().lower()
if value == "local":
return "docker"
return value or "memory"
class CreateSandboxRequest(BaseModel): class CreateSandboxRequest(BaseModel):
sandbox_id: str sandbox_id: str
thread_id: str thread_id: str
@ -750,8 +757,9 @@ class SandboxIdleReaper:
def _build_backend(): def _build_backend():
backend = (os.getenv("PROVISIONER_BACKEND", "memory") or "memory").strip().lower() backend = canonical_backend_name(os.getenv("PROVISIONER_BACKEND", "memory"))
if backend in {"docker", "local"}: # "local" remains a legacy alias for the Docker-backed provisioner.
if backend == "docker":
return LocalContainerProvisionerBackend(), backend return LocalContainerProvisionerBackend(), backend
if backend == "kubernetes": if backend == "kubernetes":
return KubernetesProvisionerBackend(), backend return KubernetesProvisionerBackend(), backend

View File

@ -16,39 +16,41 @@ Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程
放到 Yuxi 里这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 `agent-sandbox` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 `sandbox-provisioner` 这个单独的服务。也就是说Yuxi 的应用层只依赖 “provisioner”而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。 放到 Yuxi 里这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 `agent-sandbox` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 `sandbox-provisioner` 这个单独的服务。也就是说Yuxi 的应用层只依赖 “provisioner”而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。
所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。配置中看到的 `local`、`docker`、`kubernetes`,都属于这一层,而不是三套互相独立的产品形态 所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。当前真正应该对外理解和配置的是 `docker`、`kubernetes`,而旧配置里的 `local` 只是 `docker` 的兼容别名,不是另一套独立实现
## 二、当前项目的真实沙盒调用链 ## 二、当前项目的真实沙盒调用链
当前仓库里,后端只支持 `SANDBOX_PROVIDER=provisioner`。当某个对话线程第一次需要执行文件操作或命令执行时,后端会基于 `thread_id` 生成一个稳定的 `sandbox_id`,然后请求 `sandbox-provisioner` 创建或复用对应沙盒。应用层拿到返回的 `sandbox_url` 之后,才会真正通过 `agent-sandbox` 客户端去调用远程沙盒的文件 API 和 shell API。 当前仓库里,后端只支持 `SANDBOX_PROVIDER=provisioner`。当某个对话线程第一次需要执行文件操作或命令执行时,后端会基于 `thread_id` 生成一个稳定的 `sandbox_id`,然后请求 `sandbox-provisioner` 创建或复用对应沙盒。应用层拿到返回的 `sandbox_url` 之后,才会真正通过 `agent-sandbox` 客户端去调用远程沙盒的文件 API 和 shell API。
调用链可以概括为Web/API 请求进入 Yuxi 后端,后端构造 `ProvisionerSandboxBackend`,再经由 `ProvisionerClient` 调用 `sandbox-provisioner``/api/sandboxes` 接口。`sandbox-provisioner` 根据 `SANDBOX_PROVISIONER_BACKEND` 选择本地内存实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。 调用链可以概括为Web/API 请求进入 Yuxi 后端,后端构造 `ProvisionerSandboxBackend`,再经由 `ProvisionerClient` 调用 `sandbox-provisioner``/api/sandboxes` 接口。`sandbox-provisioner` 根据 `SANDBOX_PROVISIONER_BACKEND` 选择内存占位实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。
当前仓库的默认配置和默认开发环境都`local`。默认值来自 `.env.template` 中的 `SANDBOX_PROVISIONER_BACKEND=local`,而运行中的 provisioner 健康检查返回的也应当是 `backend=local`。这意味着我们用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。 当前仓库的默认配置和默认开发环境都应该理解为 `docker`。虽然旧环境变量值 `local` 仍然兼容,但代码会把它归一化为 `docker`。因此,正常情况下运行中的 provisioner 健康检查应返回 `backend=docker`。这意味着我们用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。
## 三、`local`、`docker`、`kubernetes` 到底分别是什么 ## 三、`docker`、`kubernetes` 以及兼容别名 `local` 到底分别是什么
当前实现里,`memory`、`local`、`docker`、`kubernetes` 一共四个值都可以出现在 provisioner 的后端配置中,但它们并不等价 当前实现里,`memory`、`docker`、`kubernetes` 是三种需要区分的语义,另外 `local` 只是 `docker` 的兼容别名
`memory` 是一个纯内存登记实现。它不会真正创建容器,也不会提供真实隔离,主要适合测试或极轻量的占位场景。它只是记录一个 `sandbox_id -> sandbox_url` 的映射,因此不能把它理解成生产可用的沙盒。 `memory` 是一个纯内存登记实现。它不会真正创建容器,也不会提供真实隔离,主要适合测试或极轻量的占位场景。它只是记录一个 `sandbox_id -> sandbox_url` 的映射,因此不能把它理解成生产可用的沙盒。
`local` 和 `docker` 在当前代码里是同一个后端实现。`sandbox-provisioner` 会把这两个值都映射到 `LocalContainerProvisionerBackend`。因此,今天在 Yuxi 里说 “Docker 模式” 和说 “local 模式”,从代码路径上看是一回事。之所以保留两个名字,主要是为了兼容已有术语和便于理解:`local` 强调“在当前这台部署机上起容器”,`docker` 强调“底层依赖 Docker Engine” `docker` 是当前默认也是推荐的本机容器后端。`sandbox-provisioner` 会把 `docker` 以及历史值 `local` 都映射到同一个 `LocalContainerProvisionerBackend`。因此,今天在 Yuxi 里说 “Docker 模式” 和旧文档里说 “local 模式”,从代码路径上看是一回事。区别只在命名:现在应统一写成 `docker``local` 仅用于兼容旧部署
`kubernetes` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。 `kubernetes` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。
因此,如果在界面、文档或者环境变量里看到 “local / docker / k8s” 这几个词最准确的理解应该是Yuxi 的应用层只有一种 provider也就是 `provisioner`provisioner 下面有多种 backend其中 `local` 和 `docker` 是同一种 Docker 本机后端的两个别名,`kubernetes` 才是另一种远程集群后端 因此,如果在界面、文档或者环境变量里看到 “docker / k8s” 这几个词最准确的理解应该是Yuxi 的应用层只有一种 provider也就是 `provisioner`provisioner 下面有多种 backend其中 `docker` 是默认的本机 Docker 后端,`kubernetes` 是另一种远程集群后端;`local` 只是前者的历史别名
## 四、默认开发模式到底是什么 ## 四、默认开发模式到底是什么
默认开发模式是 Docker Compose 启动整个项目,再由 `sandbox-provisioner``local` 后端去创建沙盒容器。也就是说,项目本身跑在 Compose 里,沙盒也跑在 Docker 里,只不过沙盒不是 Compose 静态声明的长期服务,而是 provisioner 按需动态拉起和回收的短生命周期容器。 默认开发模式是 Docker Compose 启动整个项目,再由 `sandbox-provisioner``docker` 后端去创建沙盒容器。也就是说,项目本身跑在 Compose 里,沙盒也跑在 Docker 里,只不过沙盒不是 Compose 静态声明的长期服务,而是 provisioner 按需动态拉起和回收的短生命周期容器。
这也是为什么在 `docker-compose.yml` 中既能看到 `api`、`worker`、`sandbox-provisioner` 这样的常驻服务,又能看到 `sandbox-provisioner` 挂载了 `/var/run/docker.sock`。这不是重复设计,而是为了让 provisioner 有能力继续调用宿主机 Docker daemon 去创建新的“每线程沙盒容器”。 这也是为什么在 `docker-compose.yml` 中既能看到 `api`、`worker`、`sandbox-provisioner` 这样的常驻服务,又能看到 `sandbox-provisioner` 挂载了 `/var/run/docker.sock`。这不是重复设计,而是为了让 provisioner 有能力继续调用宿主机 Docker daemon 去创建新的“每线程沙盒容器”。
换句话说,当前项目不存在单独的 “纯宿主机 local 模式”。默认所谓 `local`,本质上仍然是 Docker 容器模式,只是这些容器是在当前这台机器上由 provisioner 动态拉起,而不是被 Kubernetes 调度。 换句话说,当前项目不存在单独的 “纯宿主机 local 模式”。旧配置里的 `local`,本质上仍然是 Docker 容器模式,只是这些容器是在当前这台机器上由 provisioner 动态拉起,而不是被 Kubernetes 调度。
这里还需要把 Compose 里的环境变量分两层看。`api` 和 `worker` 关注的是应用层变量,例如 `SANDBOX_PROVIDER`、`SANDBOX_PROVISIONER_URL`、`SANDBOX_VIRTUAL_PATH_PREFIX`、`SANDBOX_EXEC_TIMEOUT_SECONDS`、`SANDBOX_MAX_OUTPUT_BYTES`。`sandbox-provisioner` 自己则有另一组变量,负责决定具体如何创建沙盒实例。两层不要混看,否则很容易误以为改了 API 环境变量就能切换底层承载方式。
## 五、Docker 本机后端是如何工作的 ## 五、Docker 本机后端是如何工作的
`SANDBOX_PROVISIONER_BACKEND=local` 或 `docker` 时,`sandbox-provisioner` 会进入 `LocalContainerProvisionerBackend`。它会检查 Docker 是否可用,解析自身容器里 `/app/saves` 这个挂载点在宿主机上的真实路径,并据此推导出线程数据目录。随后它为每个 `thread_id` 准备一个稳定的 `sandbox_id`,把容器命名为类似 `yuxi-sandbox-<id>` 的形式,并在 Docker 网络中启动真正的沙盒镜像。 `SANDBOX_PROVISIONER_BACKEND=docker` 时,或者为了兼容旧配置读取到 `local` 时,`sandbox-provisioner` 会进入 `LocalContainerProvisionerBackend`。它会检查 Docker 是否可用,解析自身容器里 `/app/saves` 这个挂载点在宿主机上的真实路径,并据此推导出线程数据目录。随后它为每个 `thread_id` 准备一个稳定的 `sandbox_id`,把容器命名为类似 `yuxi-sandbox-<id>` 的形式,并在 Docker 网络中启动真正的沙盒镜像。
这个沙盒镜像默认来自 `SANDBOX_IMAGE`,容器内部监听的端口默认是 `8080`。provisioner 在启动容器时,会把这个端口随机映射到宿主机上的一个可用端口,再用 `DOCKER_SANDBOX_HOST` 拼出形如 `http://host.docker.internal:<random_port>` 的访问地址。Yuxi 后端拿到的就是这个地址。 这个沙盒镜像默认来自 `SANDBOX_IMAGE`,容器内部监听的端口默认是 `8080`。provisioner 在启动容器时,会把这个端口随机映射到宿主机上的一个可用端口,再用 `DOCKER_SANDBOX_HOST` 拼出形如 `http://host.docker.internal:<random_port>` 的访问地址。Yuxi 后端拿到的就是这个地址。
@ -56,6 +58,14 @@ Docker 后端在启动沙盒时,会挂载两类关键目录。第一类是线
为了避免长期空闲的沙盒一直占资源provisioner 还带了一个 idle reaper。它会记录每个沙盒最近一次被 touch 的时间,超过 `SANDBOX_IDLE_TIMEOUT_SECONDS` 之后自动删除。当前默认空闲超时是 120 秒,但如果这个值小于命令执行超时,系统会自动把它提高到“命令超时 + 30 秒”,以免执行中的任务被误回收。 为了避免长期空闲的沙盒一直占资源provisioner 还带了一个 idle reaper。它会记录每个沙盒最近一次被 touch 的时间,超过 `SANDBOX_IDLE_TIMEOUT_SECONDS` 之后自动删除。当前默认空闲超时是 120 秒,但如果这个值小于命令执行超时,系统会自动把它提高到“命令超时 + 30 秒”,以免执行中的任务被误回收。
对应到 `docker-compose.yml``docker-compose.prod.yml`,当前 `sandbox-provisioner` 实际会读取的 Docker 后端相关变量主要是这些:
- 通用变量:`PROVISIONER_BACKEND`、`SANDBOX_IMAGE`、`SANDBOX_CONTAINER_PORT`、`SANDBOX_HEALTH_TIMEOUT_SECONDS`、`SANDBOX_IDLE_TIMEOUT_SECONDS`、`SANDBOX_IDLE_CHECK_INTERVAL_SECONDS`、`SANDBOX_EXEC_TIMEOUT_SECONDS`、`MEMORY_SANDBOX_URL_TEMPLATE`
- Docker 后端变量:`DOCKER_NETWORK`、`DOCKER_THREADS_HOST_PATH`、`DOCKER_SANDBOX_PREFIX`、`DOCKER_SANDBOX_HOST`
- 容器代理变量:`HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY`
其中 `DOCKER_SANDBOX_HOST` 只在 Docker 后端下用于拼接返回给 API 的 `sandbox_url`。`DOCKER_THREADS_HOST_PATH` 也是 Docker 后端专用如果不显式传入provisioner 会尝试根据自身容器挂载反推出宿主机路径。
## 六、Kubernetes 后端是如何工作的 ## 六、Kubernetes 后端是如何工作的
`SANDBOX_PROVISIONER_BACKEND=kubernetes` 时,`sandbox-provisioner` 会改用 Kubernetes Python 客户端。它会先加载 kubeconfig 或集群内配置,然后在指定的 namespace 中创建一个沙盒 Pod再创建一个同名的 NodePort Service把这个 Service 的 `nodePort` 暴露给 Yuxi 后端使用。 `SANDBOX_PROVISIONER_BACKEND=kubernetes` 时,`sandbox-provisioner` 会改用 Kubernetes Python 客户端。它会先加载 kubeconfig 或集群内配置,然后在指定的 namespace 中创建一个沙盒 Pod再创建一个同名的 NodePort Service把这个 Service 的 `nodePort` 暴露给 Yuxi 后端使用。
@ -66,6 +76,16 @@ Kubernetes 后端下,沙盒还是同一套镜像,还是暴露同样的 HTTP
Kubernetes 后端还需要一个 `NODE_HOST`。这是因为当前实现使用的是 NodePort Service而不是 Ingress也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 `http://<NODE_HOST>:<nodePort>` 返回给 Yuxi 后端。所以 `NODE_HOST` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。 Kubernetes 后端还需要一个 `NODE_HOST`。这是因为当前实现使用的是 NodePort Service而不是 Ingress也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 `http://<NODE_HOST>:<nodePort>` 返回给 Yuxi 后端。所以 `NODE_HOST` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。
当前 Compose 中与 Kubernetes 后端对应的变量主要是:
- `K8S_NAMESPACE`
- `KUBECONFIG_PATH`
- `NODE_HOST`
- `THREAD_PVC`
- `SKILLS_PVC`
其中真正决定运行时挂载的是 `THREAD_PVC`。`SKILLS_PVC` 目前只保留为代码层读取字段,并没有进入实际 Pod 挂载。
## 七、如果要使用“远程 K8s”应该怎么接 ## 七、如果要使用“远程 K8s”应该怎么接
这里最容易误解的一点是,所谓“选择远程 K8s”并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:我们把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server这个集群就是它要操作的“远程 K8s”。 这里最容易误解的一点是,所谓“选择远程 K8s”并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:我们把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server这个集群就是它要操作的“远程 K8s”。
@ -82,6 +102,7 @@ services:
- K8S_NAMESPACE=yuxi-know - K8S_NAMESPACE=yuxi-know
- KUBECONFIG_PATH=/root/.kube/config - KUBECONFIG_PATH=/root/.kube/config
- THREAD_PVC=yuxi-thread - THREAD_PVC=yuxi-thread
- SKILLS_PVC=yuxi-skills
- NODE_HOST=203.0.113.10 - NODE_HOST=203.0.113.10
volumes: volumes:
- ~/.kube/config:/root/.kube/config:ro - ~/.kube/config:/root/.kube/config:ro
@ -140,14 +161,14 @@ skills 的结合方式分成两层。第一层是提示词层,`SkillsMiddlewar
## 十一、当前推荐如何使用 Docker 沙盒 ## 十一、当前推荐如何使用 Docker 沙盒
如果只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。 如果只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `docker`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。
最小必要配置通常就是下面这几项: 最小必要配置通常就是下面这几项:
```env ```env
SANDBOX_PROVIDER=provisioner SANDBOX_PROVIDER=provisioner
SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002 SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002
SANDBOX_PROVISIONER_BACKEND=local SANDBOX_PROVISIONER_BACKEND=docker
SANDBOX_VIRTUAL_PATH_PREFIX=/home/gem/user-data SANDBOX_VIRTUAL_PATH_PREFIX=/home/gem/user-data
SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal
``` ```
@ -159,7 +180,7 @@ docker compose up -d
curl http://localhost:8002/health curl http://localhost:8002/health
``` ```
如果健康检查返回 `backend: local`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。 如果健康检查返回 `backend: docker`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。
如果运行在 Linux而不是 Docker Desktop那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成当前网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url` 如果运行在 Linux而不是 Docker Desktop那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成当前网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`
@ -173,11 +194,11 @@ curl http://localhost:8002/health
当前项目不应再按“应用直接管理一个长期存在的本地 sandbox 服务”去理解。更准确的认识应该是Yuxi 只管理线程和上下文provisioner 负责创建线程对应的沙盒实例;文件系统不是简单地暴露一个容器根目录,而是把可写工作区、只读 skills 和只读知识库组合成一个受控命名空间。 当前项目不应再按“应用直接管理一个长期存在的本地 sandbox 服务”去理解。更准确的认识应该是Yuxi 只管理线程和上下文provisioner 负责创建线程对应的沙盒实例;文件系统不是简单地暴露一个容器根目录,而是把可写工作区、只读 skills 和只读知识库组合成一个受控命名空间。
因此,当你在界面上“启用沙盒”或者在文档里“选择 K8s”时本质上做的不是切换一段业务逻辑而是在切换 provisioner 的底层实例承载方式。选择 `local/docker` 时,沙盒由当前部署机上的 Docker daemon 动态创建。选择 `kubernetes` 时,沙盒由目标 K8s 集群动态创建。Yuxi 自己始终只面对一个 provisioner 服务地址。 因此,当你在界面上“启用沙盒”或者在文档里“选择 K8s”时本质上做的不是切换一段业务逻辑而是在切换 provisioner 的底层实例承载方式。选择 `docker` 时,沙盒由当前部署机上的 Docker daemon 动态创建;旧值 `local` 也会落到这条路径。选择 `kubernetes` 时,沙盒由目标 K8s 集群动态创建。Yuxi 自己始终只面对一个 provisioner 服务地址。
## 十四、排障时建议先看什么 ## 十四、排障时建议先看什么
如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。 如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。默认 Docker 部署下这里应看到 `backend=docker`,即使你沿用了旧的 `SANDBOX_PROVISIONER_BACKEND=local`接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。
如果怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。 如果怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。

View File

@ -63,6 +63,7 @@
- 修复 `/home/gem/user-data` 根目录文件不可见的问题:根目录现在会同时展示 thread 目录下的真实文件和 `workspace` 入口,不再只保留固定命名空间目录 - 修复 `/home/gem/user-data` 根目录文件不可见的问题:根目录现在会同时展示 thread 目录下的真实文件和 `workspace` 入口,不再只保留固定命名空间目录
- 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标 - 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标
- 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败 - 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败
- 修正沙盒 provisioner backend 命名与配置说明:统一对外使用 `docker` / `kubernetes`,保留 `local` 作为兼容别名;同步清理 compose 中未生效的 provisioner 环境变量、补齐 K8s 相关变量注释,并更新沙盒架构文档中的默认模式与 backend 描述
<!-- 添加到这里 --> <!-- 添加到这里 -->