zcbot/core/sandbox/pool.py

476 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Per-user sandbox 容器池(DESIGN §7.5)。
命名:`zcbot-sandbox-<user_id>`(user_id = UUID 标准串带 dash,与 bind mount
源路径 `<workspace>/users/<user_id>/` 对齐 ── `docker ps` 看到容器名能直接 grep
出 workspace 目录)。蓝绿双实例(RUN.md B 档,`ZCBOT_INSTANCE=blue/green`)时名字
变 `zcbot-sandbox-<instance>-<user_id>` + 追加 `zcbot.instance` label,shutdown_all
只清自己实例的容器 ── 并存期两实例各管各的,互不清扫(代价:切换窗口同一 user 可能
短暂双容器,idle reaper 会收)。单实例部署不设 env,命名/清扫行为不变。
生命周期:
- `ensure(user_id)`:per-user `threading.Lock` 串行化 → `docker inspect` 探测 →
已 running 直接返;exists-but-stopped 先 `rm -f` 重起(保证 iptables 重新 apply);
不存在 `docker run`
- `mark_active(user_id)`:exec 完更新 in-memory `_last_active[uid]=now`(docker labels
不可运行时修改 ── Docker 23+ 移除 `docker update --label-add` 支持)
- `reap_idle()`:周期任务,扫 `_last_active` dict,>`idle_ttl` 的 `docker rm -f`
- `shutdown_all()`:app 启动时清前驱孤儿(`docker ps --filter label=zcbot.product=sandbox`)
API 全同步 —— ensure 主要使用方是 AgentLoop / DockerExecutor,跑在 web BG 线程内
天然同步;reaper 跑在 uvicorn 主 loop 里,通过 `run_in_executor` 包一层调本类 sync 方法。
threading.Lock 跨线程有效,asyncio.Lock 会被 ephemeral loop 创建 / 销毁绕过保护。
幂等性:
- ensure 在重复调用时跨 daemon round-trip < 100ms(纯 `docker inspect`);per-user lock
防同 user 两并发 `docker run --name` 撞 "Conflict"(虽然 docker 本身会 reject,提前
锁更干净)
- reaper 只杀 dict 里有记录的容器 ── 重启后 dict 空 → 不杀历史孤儿(这条由 startup
`shutdown_all` 兜底)
"""
from __future__ import annotations
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID
from .network import NETWORK_NAME, ensure_network
CONTAINER_NAME_PREFIX = "zcbot-sandbox-"
LABEL_PRODUCT_KEY = "zcbot.product"
LABEL_PRODUCT_VALUE = "sandbox"
# bg proc 专用容器(DESIGN §8.12):product=proc + 不带 instance label ──
# 与 sandbox 容器的 idle reaper / shutdown_all(按 product=sandbox 过滤)生命周期
# 解耦,蓝绿切换/重启都不会误杀;回收由 core/procs.py::sweep 负责
LABEL_PRODUCT_PROC = "proc"
LABEL_PROC_DIR_KEY = "zcbot.proc_dir"
PROC_CONTAINER_PREFIX = "zcbot-proc-"
LABEL_USER_ID_KEY = "zcbot.user_id"
LABEL_INSTANCE_KEY = "zcbot.instance"
# 蓝绿实例名(与 web/app.py 同款读法);进程生命周期内不变,import 时读一次
INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
DEFAULT_IMAGE = "zcbot-sandbox:latest"
DEFAULT_IDLE_TTL_SECONDS = 600
# 容器资源限制默认值(可被 yaml `sandbox.*` / env override,详 SandboxPool ctor)
DEFAULT_MEMORY = "4g"
DEFAULT_CPUS = "2.0"
# pids-limit 把**线程**也计数:chromium headless 一次启动(browser+GPU+network+renderer
# 多进程)就 ~150-200 线程,256 本来就贴边;shell 超时又只杀 host 侧 docker CLI,容器内
# mmdc+chromium 树留着(见 executor_docker.py 头注 Cancel limitation),残留几棵就把配额
# 吃穿 → 后续 chromium 全部 pthread_create EAGAIN 崩(2026-07 生产实测,mermaid 渲染
# 连挂近一个月)。1024 给足 2-3 次并存 chromium + 正常任务余量,仍防 fork 炸弹。
DEFAULT_PIDS_LIMIT = 1024
# chromium(mmdc 渲 mermaid / puppeteer)默认走 /dev/shm,docker 不传 --shm-size 时
# 只给 64MB,起不来就一直挂到 timeout。镜像备的 puppeteer-config 有 --disable-dev-shm-usage,
# 但模型不一定用那份;这里从根上把 /dev/shm 撑到够用,任何 chromium 路径都不再挂。
# 从 --memory(默 4g)里切,512m 是上限非占用(tmpfs 按需用)。
DEFAULT_SHM_SIZE = "512m"
DEFAULT_TMP_SIZE = "1g"
def container_name(user_id: UUID) -> str:
if INSTANCE:
return f"{CONTAINER_NAME_PREFIX}{INSTANCE}-{user_id}"
return f"{CONTAINER_NAME_PREFIX}{user_id}"
def _now() -> int:
return int(time.time())
def _container_exists(name: str) -> bool:
"""任何 state(running / exited / created)都算存在。"""
r = subprocess.run(
["docker", "inspect", "--type=container", name],
capture_output=True, text=True,
)
return r.returncode == 0
def _container_running(name: str) -> bool:
r = subprocess.run(
["docker", "inspect", "--type=container",
"--format={{.State.Running}}", name],
capture_output=True, text=True,
)
return r.returncode == 0 and r.stdout.strip() == "true"
def _container_session_id(name: str) -> str:
r = subprocess.run(["docker", "inspect", "--format={{.Id}}", name], capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else name
class SandboxPool:
def __init__(
self,
user_root_base: Path,
repo_root: Optional[Path] = None,
image: Optional[str] = None,
runtime: Optional[str] = None,
idle_ttl: Optional[int] = None,
pg_ips: Optional[str] = None,
memory: Optional[str] = None,
cpus: Optional[str] = None,
pids_limit: Optional[int] = None,
shm_size: Optional[str] = None,
tmp_size: Optional[str] = None,
dns: Optional[List[str]] = None,
capacity_cfg: Optional[Dict[str, object]] = None,
) -> None:
"""
user_root_base: per-user 子树父目录,典型 `<workspace>/users`。bind mount 源
= `user_root_base / <user_id>`,目标 `/workspace`。
repo_root: zcbot repo 根(`core/paths.py::ROOT`)。**fs 工具进容器后**
(read/write/edit/glob/grep)`/sandbox/skills:ro` mount 让
容器内 read SKILL 内部 references 的 path 能解析(skill
在 host 上是 repo 内代码,容器 user_root 是用户文件,两者
正交)。None → 不挂 skills,只走 user_root 边界。
image: sandbox 镜像 tag(默 env `ZCBOT_SANDBOX_IMAGE`)
runtime: `docker run --runtime` 值(runc / runsc / kata 等);空 = 默认
(env `ZCBOT_SANDBOX_RUNTIME`)。§7.5 #5 / §7.9 升级表 ── 切
gVisor / Firecracker 时改这一项即可,应用层零改动。
idle_ttl: 秒;`mark_active` 时间戳 < now - ttl 的容器被 reap_idle 杀
(env `ZCBOT_SANDBOX_IDLE_TTL`,默 300)
pg_ips: 逗号分隔的 PG IP 串,塞容器 `ZCBOT_PG_IPS` env,init.sh 加 DROP 规则
(env `ZCBOT_PG_IPS`)。defense-in-depth ── 即便落内网三段。
memory/cpus/pids_limit/shm_size:
容器资源限制,默 4g/2.0/1024/512m;env(`ZCBOT_SANDBOX_MEMORY` 等)
override caller 参数 override 默认。改后重启 web 生效,新起的
容器用新值;已 running 不变(idle 10min 回收后下次起按新值)。
shm_size 撑 chromium 的 /dev/shm(默 64MB 不够,mmdc 渲图会挂)。
"""
self.user_root_base = user_root_base
self.repo_root = repo_root
self.image: str = (
image or os.getenv("ZCBOT_SANDBOX_IMAGE") or DEFAULT_IMAGE
)
self.runtime = runtime or os.getenv("ZCBOT_SANDBOX_RUNTIME") or ""
self.idle_ttl = idle_ttl if idle_ttl is not None else int(
os.getenv("ZCBOT_SANDBOX_IDLE_TTL", str(DEFAULT_IDLE_TTL_SECONDS))
)
self.pg_ips = pg_ips if pg_ips is not None else os.getenv("ZCBOT_PG_IPS", "")
# 资源限制:env > caller > 默
self.memory = os.getenv("ZCBOT_SANDBOX_MEMORY") or memory or DEFAULT_MEMORY
self.cpus = os.getenv("ZCBOT_SANDBOX_CPUS") or cpus or DEFAULT_CPUS
self.shm_size = os.getenv("ZCBOT_SANDBOX_SHM_SIZE") or shm_size or DEFAULT_SHM_SIZE
self.tmp_size = os.getenv("ZCBOT_SANDBOX_TMP_SIZE") or tmp_size or DEFAULT_TMP_SIZE
self.pids_limit = int(
os.getenv("ZCBOT_SANDBOX_PIDS_LIMIT")
or (pids_limit if pids_limit is not None else DEFAULT_PIDS_LIMIT)
)
# DNS:env(逗号分隔)> caller > 默空(让 docker 自己探测)
env_dns = os.getenv("ZCBOT_SANDBOX_DNS", "").strip()
if env_dns:
self.dns: List[str] = [x.strip() for x in env_dns.split(",") if x.strip()]
else:
self.dns = list(dns) if dns else []
self._dict_lock = threading.Lock() # 保护 _locks / _last_active 的字典级 race
self._locks: Dict[UUID, threading.Lock] = {}
self._last_active: Dict[UUID, int] = {}
self._active_execs: Dict[UUID, int] = {}
from .capacity import ExecCapacity
self.capacity = ExecCapacity(self.user_root_base.parent / ".sandbox", capacity_cfg)
self.capacity.idle_ttl_seconds = self.idle_ttl
def _lock_for(self, user_id: UUID) -> threading.Lock:
with self._dict_lock:
if user_id not in self._locks:
self._locks[user_id] = threading.Lock()
return self._locks[user_id]
def ensure(self, user_id: UUID) -> str:
"""返回容器名;create-or-reuse 原子。同步阻塞,主调方 AgentLoop 已在 BG 线程。"""
with self._lock_for(user_id):
name = container_name(user_id)
if _container_running(name):
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
if _container_exists(name):
# stopped / crashed ── rm 重起。iptables 规则随容器生命周期重新 apply。
self._scan_before_remove(name, user_id, "foreground")
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, check=False,
)
self._docker_run(user_id, name)
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
def _ensure_resolv_conf_file(self) -> Optional[Path]:
"""准备 host 侧 resolv.conf 用于 bind mount 覆盖容器内 ro mount。
docker daemon 默 mount /etc/resolv.conf 是 ro,且 init.sh 在 `--read-only`
rootfs 下写不进 ── 改用 host 侧准备一份 + `-v <host>:/etc/resolv.conf:ro`
覆盖,绕开 embedded DNS。无 dns 配置返 None,不 mount(走 docker 默 embedded DNS)。
"""
if not self.dns:
return None
sandbox_dir = self.user_root_base.parent / ".sandbox"
try:
sandbox_dir.mkdir(parents=True, exist_ok=True)
except OSError:
return None
resolv = sandbox_dir / "resolv.conf"
content = "".join(f"nameserver {ip}\n" for ip in self.dns if ip)
try:
resolv.write_text(content, encoding="utf-8")
except OSError:
return None
return resolv
def run_proc_container(self, user_id: UUID, proc_id: str, proc_dir: str) -> str:
"""bg proc 专用容器:与 sandbox 同款硬化(iptables init / read-only / 资源限额),
但 product=proc + 无 instance label → reaper/shutdown_all 都不碰它,dockerd 托管,
zcbot 重启与蓝绿切换不中断。回收:procs.sweep / check_process 见 exit_code 后 rm -f。"""
name = f"{PROC_CONTAINER_PREFIX}{proc_id}"
self._docker_run(
user_id, name,
product=LABEL_PRODUCT_PROC,
extra_labels={LABEL_PROC_DIR_KEY: proc_dir},
)
return name
def _docker_run(
self,
user_id: UUID,
name: str,
*,
product: str = LABEL_PRODUCT_VALUE,
extra_labels: Optional[Dict[str, str]] = None,
) -> None:
"""同步阻塞;由 ensure 在 to_thread 里调。instance label 只挂 sandbox 容器
(proc 容器要跨实例存活,不能被任何实例的 shutdown_all 认领)。"""
user_root = self.user_root_base / str(user_id)
user_root.mkdir(parents=True, exist_ok=True)
resolv_file = self._ensure_resolv_conf_file()
is_sandbox = product == LABEL_PRODUCT_VALUE
label_args: List[str] = [
"--label", f"{LABEL_PRODUCT_KEY}={product}",
"--label", f"{LABEL_USER_ID_KEY}={user_id}",
]
if INSTANCE and is_sandbox:
label_args += ["--label", f"{LABEL_INSTANCE_KEY}={INSTANCE}"]
for k, v in (extra_labels or {}).items():
label_args += ["--label", f"{k}={v}"]
cmd: List[str] = [
"docker", "run", "-d",
"--name", name,
*label_args,
"--network", NETWORK_NAME,
# §7.5 硬限制(任一缺失视为 hardening 未完成)
"--read-only", # rootfs read-only
"--tmpfs", f"/tmp:exec,size={self.tmp_size},mode=1777",
f"--shm-size={self.shm_size}", # chromium/mmdc 的 /dev/shm,默 64MB 不够会挂(DEFAULT_SHM_SIZE)
"--cap-drop=ALL", # 默全丢
"--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到
"--security-opt=no-new-privileges",
f"--pids-limit={self.pids_limit}",
f"--memory={self.memory}",
f"--cpus={self.cpus}",
"-v", f"{user_root}:/workspace",
"-e", f"ZCBOT_PG_IPS={self.pg_ips}",
"--restart=no",
]
# 显式 DNS 两层(主路径 + fallback):
# 1. **主**:host 侧 resolv.conf bind mount 覆盖(下面 -v),user 指定 mount
# 优先级 > daemon 默注入,绕开 embedded DNS(127.0.0.11)
# 2. **fallback**:ZCBOT_DNS env 传给 init.sh 启动时 cat > /etc/resolv.conf,
# 容器内 /etc/resolv.conf 是 ro 时写失败也 warn 继续(init.sh 已 robust)。
# 保留以防 host 侧 file mount 因某种原因失败的备份路径
# 用 docker `--dns` flag 不行 ── user-defined bridge network 上 `--dns` 只改
# docker daemon 给 embedded DNS 的上游,不动容器 resolv.conf;daemon 上游
# 探测在腾讯云轻量等场景下挂掉,embedded DNS 自身 forward 仍跪
if resolv_file is not None:
cmd += ["-v", f"{resolv_file}:/etc/resolv.conf:ro"]
if self.dns:
cmd += ["-e", f"ZCBOT_DNS={','.join(self.dns)}"]
# repo skills 只读 mount ── fs 工具进容器后(read/glob/grep)能 access
# SKILL.md 内引用的 references/*.md。host 上 zcbot/skills/ 是项目代码,
# 跟用户 working_dir 正交,只读防容器内进程改 skill 实现。
if self.repo_root is not None:
skills_path = (self.repo_root / "skills").resolve()
if skills_path.is_dir():
cmd += ["-v", f"{skills_path}:/sandbox/skills:ro"]
# 平台渲染层(rendering/)只读 mount ── 各 skill 出 docx/pdf 调
# `python /sandbox/rendering/render.py`,不再自带 render 脚本。与 skills 同款 ro。
rendering_path = (self.repo_root / "rendering").resolve()
if rendering_path.is_dir():
cmd += ["-v", f"{rendering_path}:/sandbox/rendering:ro"]
if self.runtime:
cmd += ["--runtime", self.runtime]
cmd.append(self.image)
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(
f"docker run {name} failed (rc={r.returncode}): {r.stderr.strip()}"
)
def mark_active(self, user_id: UUID) -> None:
"""每次 `docker exec` 完调一次,刷新 idle 计时。"""
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id))
def exec_started(self, user_id: UUID) -> None:
"""普通容器内 exec 开始;显式计数防止 idle reaper 误删长命令。"""
with self._dict_lock:
self._active_execs[user_id] = self._active_execs.get(user_id, 0) + 1
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=1)
def exec_finished(self, user_id: UUID) -> None:
with self._dict_lock:
n = max(0, self._active_execs.get(user_id, 0) - 1)
if n:
self._active_execs[user_id] = n
else:
self._active_execs.pop(user_id, None)
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=-1)
def _scan_before_remove(self, name: str, user_id: UUID, kind: str, session_id: Optional[str] = None) -> None:
"""扫描/落库均 best-effort调用方无论如何继续删除容器。"""
try:
from .package_scans import scan_and_persist
scan_and_persist(name, user_id, kind, session_id or _container_session_id(name))
except Exception as exc:
print(f"[sandbox-package-scan] finalizer failed container={name}: {type(exc).__name__}: {exc}")
def reap_idle(self) -> List[str]:
"""杀超过 idle_ttl 没活跃的容器。返回已杀容器名列表(供日志 / 审计)。"""
removed: List[str] = []
cutoff = _now() - self.idle_ttl
for uid, ts in list(self._last_active.items()):
if ts < cutoff and self._active_execs.get(uid, 0) == 0:
name = container_name(uid)
self._scan_before_remove(name, uid, "foreground")
r = subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, text=True,
)
if r.returncode == 0:
removed.append(name)
self.capacity.remove_container(name)
# 无论 rm 成功与否,从 dict 移除 ── 失败则下次启动靠 shutdown_all 兜底
del self._last_active[uid]
return removed
def shutdown_all(self) -> List[str]:
"""杀本实例名下所有 sandbox 容器。
典型用途:① app 启动时清前驱进程留下的孤儿 ② 测试 / 维护手动调。
蓝绿(ZCBOT_INSTANCE 在)时按 zcbot.instance label 再过滤一层,只清自己
实例的 ── 另一实例正在跑的容器碰不到;单实例部署清全部(含无 instance
label 的历史遗留)。
"""
filters = ["--filter", f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"]
if INSTANCE:
filters += ["--filter", f"label={LABEL_INSTANCE_KEY}={INSTANCE}"]
list_r = subprocess.run(
["docker", "ps", "-aq", *filters],
capture_output=True, text=True,
)
if list_r.returncode != 0 or not list_r.stdout.strip():
return []
ids = list_r.stdout.strip().splitlines()
for cid in ids:
info = subprocess.run(
["docker", "inspect", "--format={{index .Config.Labels \"zcbot.user_id\"}}", cid],
capture_output=True, text=True,
)
try:
uid = UUID(info.stdout.strip())
except (ValueError, AttributeError):
continue
self._scan_before_remove(cid, uid, "foreground", cid)
try:
name_r = subprocess.run(["docker", "inspect", "--format={{.Name}}", cid], capture_output=True, text=True)
self.capacity.remove_container(name_r.stdout.strip().lstrip("/") or cid)
except Exception:
pass
subprocess.run(
["docker", "rm", "-f", *ids],
capture_output=True, text=True,
)
# 反查容器名给调用方记日志(rm 前先 inspect)── 这里简化只返 id
self._last_active.clear()
return ids
def runtime_snapshot(self) -> dict:
try:
listed = subprocess.run(
["docker", "ps", "--format={{.Names}}", "--filter", f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"],
capture_output=True, text=True, timeout=15,
)
if listed.returncode == 0:
self.capacity.reconcile_containers(set(listed.stdout.split()))
except (OSError, subprocess.TimeoutExpired):
pass
snap = self.capacity.snapshot()
now = _now()
snap["idle_ttl_seconds"] = self.idle_ttl
try:
from core import procs
items = []
for uroot in self.user_root_base.iterdir():
items.extend(procs.list_all_procs(uroot))
snap["background_queued"] = sum(x.get("_status") == "queued" for x in items)
snap["running_proc_containers"] = sum(x.get("_status") == "running" and x.get("backend") == "docker" for x in items)
except OSError:
snap["background_queued"] = 0
snap["running_proc_containers"] = 0
return snap
def setup_pool(
user_root_base: Path,
repo_root: Optional[Path] = None,
sandbox_cfg: Optional[Dict[str, object]] = None,
) -> SandboxPool:
"""app 启动便捷入口:ensure 网络存在 + 返回 pool 实例。
`sandbox_cfg` 是 agent.yaml 的 `sandbox` 段(dict),含 memory/cpus/pids_limit;
没传走 env / 默认值。env 仍可独立 override(SandboxPool ctor 里处理优先级)。
典型用法(lifespan 启动钩子):
from core.paths import ROOT
cfg = load_config()
pool = setup_pool(workspace / "users", repo_root=ROOT,
sandbox_cfg=cfg.get("sandbox") or {})
pool.shutdown_all() # 清前驱孤儿
"""
ensure_network()
cfg = sandbox_cfg or {}
dns_cfg = cfg.get("dns") or []
if not isinstance(dns_cfg, list):
dns_cfg = []
memory = cfg.get("memory")
cpus = cfg.get("cpus")
pids_limit = cfg.get("pids_limit")
shm_size = cfg.get("shm_size")
tmp_size = cfg.get("tmp_size")
return SandboxPool(
user_root_base=user_root_base,
repo_root=repo_root,
idle_ttl=int(str(cfg.get("idle_ttl_seconds"))) if cfg.get("idle_ttl_seconds") is not None else None,
memory=memory if isinstance(memory, str) else None,
cpus=str(cpus) if cpus is not None else None,
pids_limit=int(str(pids_limit)) if pids_limit is not None else None,
shm_size=shm_size if isinstance(shm_size, str) else None,
tmp_size=tmp_size if isinstance(tmp_size, str) else None,
dns=[str(x) for x in dns_cfg],
capacity_cfg=cfg,
)