From b98eee45b6139636711f4737af0c42333b006345 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Mon, 27 Apr 2026 16:26:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20sandbox.env=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B9=B6=E5=9C=A8=20LocalContainerProvisione?= =?UTF-8?q?rBackend=20=E4=B8=AD=E5=8A=A0=E8=BD=BD=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 1 + docker-compose.yml | 1 + docker/sandbox_provisioner/app.py | 14 +++ docker/sandbox_provisioner/requirements.txt | 1 + docker/sandbox_provisioner/sandbox.env | 1 + docs/agents/sandbox-architecture.md | 104 +++++++++++++++++++- 6 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 docker/sandbox_provisioner/sandbox.env diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c894920d..e5efe4cb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -100,6 +100,7 @@ services: container_name: sandbox-provisioner volumes: - ./saves:/app/saves + - ./docker/sandbox_provisioner/sandbox.env:/app/sandbox.env:ro - /var/run/docker.sock:/var/run/docker.sock networks: - app-network diff --git a/docker-compose.yml b/docker-compose.yml index 98467a6b..cd4c8522 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,7 @@ services: volumes: - ./saves:/app/saves - ./docker/sandbox_provisioner/app.py:/app/app.py:ro + - ./docker/sandbox_provisioner/sandbox.env:/app/sandbox.env:ro - /var/run/docker.sock:/var/run/docker.sock ports: - "8002:8002" diff --git a/docker/sandbox_provisioner/app.py b/docker/sandbox_provisioner/app.py index 8789198b..8cc433db 100644 --- a/docker/sandbox_provisioner/app.py +++ b/docker/sandbox_provisioner/app.py @@ -12,6 +12,7 @@ from urllib import request from fastapi import FastAPI, HTTPException from pydantic import BaseModel +from dotenv import dotenv_values logger = logging.getLogger(__name__) @@ -114,6 +115,16 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout_seconds: int = 30) -> bool: class LocalContainerProvisionerBackend: + _SANDBOX_ENV_FILE = Path(__file__).parent / "sandbox.env" + + @staticmethod + def _load_sandbox_env() -> dict[str, str]: + """Parse sandbox.env and return environment variables to inject into sandbox containers.""" + if LocalContainerProvisionerBackend._SANDBOX_ENV_FILE.exists(): + return dotenv_values(LocalContainerProvisionerBackend._SANDBOX_ENV_FILE) + + return {} + def __init__(self): import docker from docker.errors import DockerException @@ -130,6 +141,7 @@ class LocalContainerProvisionerBackend: self._container_prefix = os.getenv("DOCKER_SANDBOX_PREFIX", "yuxi-sandbox") self._sandbox_host = os.getenv("DOCKER_SANDBOX_HOST", "host.docker.internal") self._health_timeout_seconds = int(os.getenv("SANDBOX_HEALTH_TIMEOUT_SECONDS", "300")) + self._sandbox_env = self._load_sandbox_env() try: self._client = docker.from_env() @@ -387,6 +399,8 @@ class LocalContainerProvisionerBackend: } if self._network: run_kwargs["network"] = self._network + if self._sandbox_env: + run_kwargs["environment"] = self._sandbox_env container = self._client.containers.run(self._sandbox_image, **run_kwargs) container.reload() diff --git a/docker/sandbox_provisioner/requirements.txt b/docker/sandbox_provisioner/requirements.txt index 7feabc1f..5bec9379 100644 --- a/docker/sandbox_provisioner/requirements.txt +++ b/docker/sandbox_provisioner/requirements.txt @@ -2,3 +2,4 @@ fastapi>=0.121 uvicorn[standard]>=0.34.2 kubernetes>=31.0.0 docker>=7.1.0 +python-dotenv>=1.0.0 diff --git a/docker/sandbox_provisioner/sandbox.env b/docker/sandbox_provisioner/sandbox.env new file mode 100644 index 00000000..0d4348e7 --- /dev/null +++ b/docker/sandbox_provisioner/sandbox.env @@ -0,0 +1 @@ +CHECK_YUXI_SANDBOX_ENV_EXISTS=True \ No newline at end of file diff --git a/docs/agents/sandbox-architecture.md b/docs/agents/sandbox-architecture.md index 5bc3ff49..91d1c4af 100644 --- a/docs/agents/sandbox-architecture.md +++ b/docs/agents/sandbox-architecture.md @@ -190,13 +190,113 @@ curl http://localhost:8002/health 从工程上看,当前实现更像“双层文件系统”。对 Agent 执行来说,真正工作的对象是远程沙盒进程暴露的文件 API;对 viewer、附件下载和一部分 artifact 查看来说,系统会优先在宿主机侧解析虚拟路径,再用本地文件读取或只读 backend 下载内容。这也是为什么你会看到既有 `ProvisionerSandboxBackend`,又有 `viewer_filesystem_service`、`SelectedSkillsReadonlyBackend`、`KnowledgeBaseReadonlyBackend` 这样的配套实现。 -## 十三、和旧版文档相比,今天最重要的理解方式 +## 十三、环境变量配置与传递链 + +sandbox-provisioner 的环境变量传递分**两层**,需要分别理解: + +### 第一层:应用层 → sandbox-provisioner + +`api` 和 `worker` 服务通过 `SANDBOX_*` 前缀的环境变量告诉后端如何连接 provisioner。这些变量定义在 `docker-compose.yml` 的 `x-api-worker-env` 锚点中: + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `SANDBOX_PROVIDER` | 提供者类型,固定为 `provisioner` | `provisioner` | +| `SANDBOX_PROVISIONER_URL` | provisioner 服务地址 | `http://sandbox-provisioner:8002` | +| `SANDBOX_VIRTUAL_PATH_PREFIX` | 虚拟路径前缀 | `/home/gem/user-data` | +| `SANDBOX_EXEC_TIMEOUT_SECONDS` | 命令执行超时时间 | `180` | +| `SANDBOX_MAX_OUTPUT_BYTES` | 最大输出字节数 | `262144` | + +### 第二层:sandbox-provisioner 内部配置 + +`sandbox-provisioner` 服务本身读取另一组环境变量,决定如何创建沙盒容器。这些变量直接写在 `docker-compose.yml` 的 `sandbox-provisioner.environment` 中: + +**通用配置:** + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `PROVISIONER_BACKEND` | 底层后端类型,`docker` 或 `kubernetes` | `docker` | +| `SANDBOX_IMAGE` | 沙盒容器镜像 | 详见 compose 文件 | +| `SANDBOX_CONTAINER_PORT` | 沙盒容器内部端口 | `8080` | +| `SANDBOX_IDLE_TIMEOUT_SECONDS` | 空闲回收时间 | `120` | +| `SANDBOX_HEALTH_TIMEOUT_SECONDS` | 健康检查超时 | `300` | + +**Docker 后端专用:** + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `DOCKER_NETWORK` | Docker 网络名称 | `yuxi-know_app-network` | +| `DOCKER_SANDBOX_PREFIX` | 沙盒容器名前缀 | `yuxi-sandbox` | +| `DOCKER_SANDBOX_HOST` | 宿主机访问地址 | `host.docker.internal` | +| `DOCKER_THREADS_HOST_PATH` | 线程数据宿主机路径 | 自动推断 | + +**Kubernetes 后端专用:** + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `K8S_NAMESPACE` | Kubernetes namespace | `yuxi-know` | +| `NODE_HOST` | Kubernetes 节点地址 | `host.docker.internal` | +| `KUBECONFIG_PATH` | kubeconfig 文件路径 | 空(使用 incluster 配置) | +| `THREAD_PVC` | 线程数据持久化卷 | `yuxi-thread` | +| `SKILLS_PVC` | 技能目录持久化卷(预留) | `yuxi-skills` | + +### 环境变量传递链 + +``` +宿主机 .env / 系统环境变量 + ↓ + docker-compose.yml + ↓ + ┌────────────────────────────────┐ + │ api/worker 服务 │ 应用层变量 (SANDBOX_*) + │ SANDBOX_PROVISIONER_URL │ + └────────────┬───────────────────┘ + ↓ HTTP 调用 + ┌────────────────────────────────┐ + │ sandbox-provisioner 服务 │ 沙盒层变量 (PROVISIONER_BACKEND, DOCKER_*, K8S_*) + │ PROVISIONER_BACKEND │ + └────────────┬───────────────────┘ + ↓ Docker API / K8s API + ┌────────────────────────────────┐ + │ 动态创建的沙盒容器 │ + └────────────────────────────────┘ +``` + +两层变量不要混看。改了 `api/worker` 的 `SANDBOX_PROVISIONER_URL` 只是改了后端找 provisioner 的地址;改了 `sandbox-provisioner` 的 `PROVISIONER_BACKEND` 才是改了 provisioner 本身用什么方式创建沙盒。 + +### sandbox.env 的特殊作用 + +`docker/sandbox_provisioner/sandbox.env` 文件的用途与上述两层变量不同。它通过 volume 挂载到 provisioner 容器内 (`/app/sandbox.env`),然后由 `LocalContainerProvisionerBackend` 在创建沙盒容器时读取,解析后的键值对会作为**环境变量注入到每个动态创建的沙盒容器**中。 + +```yaml +# docker-compose.yml 中 sandbox-provisioner 的挂载 +sandbox-provisioner: + volumes: + - ./docker/sandbox_provisioner/sandbox.env:/app/sandbox.env:ro +``` + +也就是说,`sandbox.env` 配置的是沙盒容器内部可见的环境变量,而不是 provisioner 本身的配置。当前该文件内容为: + +```env +CHECK_YUXI_SANDBOX_ENV_EXISTS=True +``` + +如果需要给所有沙盒容器注入额外的环境变量(如代理配置、认证信息等),可以添加到 `sandbox.env` 文件中。 + +### 配置方式汇总 + +| 配置目标 | 配置位置 | 示例变量 | +|----------|----------|----------| +| 应用层连接 provisioner | `.env` 或 compose 环境 | `SANDBOX_PROVISIONER_URL` | +| provisioner 自身行为 | `.env` 或 compose 环境 | `PROVISIONER_BACKEND`, `DOCKER_*` | +| 沙盒容器内部环境 | `sandbox.env` 文件 | 代理、认证等运行时变量 | + +## 十四、和旧版文档相比,今天最重要的理解方式 当前项目不应再按“应用直接管理一个长期存在的本地 sandbox 服务”去理解。更准确的认识应该是:Yuxi 只管理线程和上下文;provisioner 负责创建线程对应的沙盒实例;文件系统不是简单地暴露一个容器根目录,而是把可写工作区、只读 skills 和只读知识库组合成一个受控命名空间。 因此,当你在界面上“启用沙盒”或者在文档里“选择 K8s”时,本质上做的不是切换一段业务逻辑,而是在切换 provisioner 的底层实例承载方式。选择 `docker` 时,沙盒由当前部署机上的 Docker daemon 动态创建;旧值 `local` 也会落到这条路径。选择 `kubernetes` 时,沙盒由目标 K8s 集群动态创建。Yuxi 自己始终只面对一个 provisioner 服务地址。 -## 十四、排障时建议先看什么 +## 十五、排障时建议先看什么 如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。默认 Docker 部署下这里应看到 `backend=docker`,即使你沿用了旧的 `SANDBOX_PROVISIONER_BACKEND=local`。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。