diff --git a/Dockerfile b/Dockerfile index 6dd2dba8..0a2dfa22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,8 @@ RUN pnpm build # ---- 运行 ---- FROM python:3.12-slim -# bubblewrap:代码沙箱。默认容器权限下它建不了命名空间,服务端会自动退回本地子进程, -# 给了额外权限才用得上(见 deploy/agentlab.env 的沙箱一节)。健康检查用 python 自己探,不装 curl +# bubblewrap:代码沙箱的第二档。默认容器权限下它建不了命名空间,给了额外权限才用得上 +# (见 deploy/agentlab.env 的沙箱一节)。第一档是下面的 microVM。健康检查用 python 自己探,不装 curl RUN apt-get update \ && apt-get install -y --no-install-recommends bubblewrap \ && rm -rf /var/lib/apt/lists/* @@ -48,13 +48,39 @@ RUN groupadd --gid 10001 agentlab \ && mkdir -p /data \ && chown 10001:10001 /data +# ---- microVM:运行时和沙箱镜像在构建时备好,部署的机器不用联网去拉 ---- +# 宿主机有 KVM、deploy.sh 把 /dev/kvm 交给容器时才用得上;拿不到 KVM 时服务端不会选它。 +# 不需要的话构建时加 --build-arg WITH_MICROVM=0(package-docker.sh --no-microvm),镜像小约 150 MB。 +# 放在拷源码之前:拉镜像要一分钟左右,源码改了这一层还能用缓存 +ARG WITH_MICROVM=1 +ARG MICROVM_IMAGE=python:3.12-slim +USER 10001:10001 +RUN <<'SH' +set -eu +[ "$WITH_MICROVM" = 1 ] || exit 0 +python - <<'PY' +import asyncio, microsandbox +async def main(): + await microsandbox.install() +asyncio.run(main()) +PY +# install() 把 wheel 自带的 msb 和 libkrunfw 又拷了一份,换成指回原件的链接,省 55 MB +bundled="$(python -c 'import microsandbox, os; print(os.path.dirname(microsandbox.__file__))')/_bundled" +for f in "$HOME"/.microsandbox/bin/* "$HOME"/.microsandbox/lib/*; do + src="$bundled/$(basename "$(dirname "$f")")/$(basename "$f")" + if [ -f "$f" ] && [ ! -L "$f" ] && cmp -s "$f" "$src"; then ln -sf "$src" "$f"; fi +done +"$HOME/.microsandbox/bin/msb" pull "$MICROVM_IMAGE" +SH + WORKDIR /app COPY LICENSE COMMERCIAL-LICENSE.md /app/ COPY backend/app /app/backend/app COPY --from=web /src/frontend/dist /app/web ENV AGENTLAB_WEB_DIST=/app/web \ - AGENTLAB_DATA_DIR=/data + AGENTLAB_DATA_DIR=/data \ + AGENTLAB_MICROVM_IMAGE=${MICROVM_IMAGE} VOLUME /data EXPOSE 8000 USER 10001:10001 @@ -69,7 +95,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --start-interval=2s - ARG VERSION=dev LABEL org.opencontainers.image.title="AgentLab" \ org.opencontainers.image.description="可视化 Agent 编排实验台" \ - org.opencontainers.image.version="${VERSION}" + org.opencontainers.image.version="${VERSION}" \ + agentlab.microvm="${WITH_MICROVM}" # 单进程:运行管理器在进程内、数据库是 SQLite,不能开多个 worker CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 3d467b3f..21994a83 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,11 @@ http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建 `--platform linux/arm64` 或 `linux/amd64`),连同一键部署脚本、配置模板和部署说明打成 `release/agentlab-<版本>-linux-.tar`。拷到只装了 Docker Engine 的离线机器上,解包、按需修改 `agentlab.env`、运行 `./deploy.sh` 即可,不需要 docker compose。安装、升级、备份、沙箱权限与常见问题见 -[deploy/README.md](deploy/README.md)。容器默认权限下代码沙箱退回本地子进程,隔离边界只有容器本身。 +[deploy/README.md](deploy/README.md)。 + +镜像里默认带 microVM 的运行时和沙箱镜像(约 150 MB,`--no-microvm` 可不带)。目标机有 KVM 时,deploy.sh +先用临时容器试启动一台 microVM,起得来就把 `/dev/kvm` 交给容器,代码在 microVM 里执行,部署时不用联网。 +没有 KVM(Mac、Windows 上的 Docker Desktop,多数云主机)时,默认权限下代码沙箱退回本地子进程,隔离边界只有容器本身。 --- @@ -496,8 +500,8 @@ Excel / CSV 若传入知识库,只能被切块检索,数字即成为模型 ### 沙箱后端选择 -设置页「运行环境」列出全部候选及其可用性,并标注当前选中项。安装 microVM 依赖且镜像已缓存时 -auto 选择 `microvm`;否则 macOS 使用 `seatbelt`、Linux 上安装 `bwrap` +设置页「运行环境」列出全部候选及其可用性,并标注当前选中项。安装 microVM 依赖、镜像已缓存, +Linux 上还要拿得到 `/dev/kvm` 时,auto 选择 `microvm`;否则 macOS 使用 `seatbelt`、Linux 上安装 `bwrap` (`apt install bubblewrap`)后使用 `bubblewrap`。装了 `bwrap` 还要能真正创建命名空间才算可用: 启动后首次选择时实际起一次 `bwrap` 探测,Docker 默认权限、Ubuntu 24.04 的 AppArmor 限制下会失败并跳过, 「选择原因」写明。均不可用时降级至 `local`——**该路径不含任何访问控制**,界面明确标红。可通过 `AGENTLAB_SANDBOX_BACKEND` 强制指定, diff --git a/backend/app/sandbox/microvm_sandbox.py b/backend/app/sandbox/microvm_sandbox.py index 5c48d483..4b1d96dc 100644 --- a/backend/app/sandbox/microvm_sandbox.py +++ b/backend/app/sandbox/microvm_sandbox.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio import logging +import os +import sys import time from dataclasses import dataclass, field from pathlib import Path @@ -34,6 +36,29 @@ _LANG = { _MAX_FILE_BYTES = 8 * 1024 * 1024 _MANIFEST_CACHE = Path.home() / ".microsandbox" / "cache" / "manifests" +_KVM_DEVICE = Path("/dev/kvm") + + +def _on_linux() -> bool: + return sys.platform.startswith("linux") + + +def kvm_problem() -> str: + """Linux 上 microVM 靠 KVM 启动:拿不到 /dev/kvm 时返回原因,能用(或不在 Linux 上)返回空串。 + + macOS 走 Hypervisor.framework,不查。以前 available() 只看运行时装没装, + 运行时和镜像打进 Docker 镜像以后,没有 KVM 的机器上 auto 会选中 microVM, + 之后每次执行代码都启动失败。 + """ + if not _on_linux(): + return "" + if not _KVM_DEVICE.exists(): + return ("这台机器没有 KVM(找不到 /dev/kvm)。" + "在容器里运行时,宿主机要支持 KVM,并把 /dev/kvm 挂进容器") + if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK): + return ("当前用户没有读写 /dev/kvm 的权限。把运行服务的用户加入 kvm 组;" + "在容器里运行时,要把宿主机上 /dev/kvm 所属的组加给容器") + return "" @dataclass @@ -86,8 +111,7 @@ class MicroVMSandbox(Sandbox): # ---------------- 可用性 ---------------- @staticmethod - def available() -> bool: - """SDK 装了、运行时也装好了才算可用。""" + def _runtime_installed() -> bool: try: import microsandbox as ms except ImportError: @@ -97,6 +121,22 @@ class MicroVMSandbox(Sandbox): except Exception: # noqa: BLE001 return False + @classmethod + def available(cls) -> bool: + """SDK 和运行时都装好、Linux 上还拿得到 KVM,才算可用。""" + return cls._runtime_installed() and not kvm_problem() + + @classmethod + def unavailable_reason(cls) -> str: + """不可用的原因;可用时返回空串。""" + try: + import microsandbox # noqa: F401 + except ImportError: + return "未安装 microsandbox(pip install 'agentlab-backend[microvm]')" + if not cls._runtime_installed(): + return "运行时未就绪(await microsandbox.install(),约 50 MB)" + return kvm_problem() + @staticmethod def image_ready() -> bool: """镜像是否已经拉到本地。 @@ -112,7 +152,7 @@ class MicroVMSandbox(Sandbox): async def health(self) -> dict[str, Any]: try: - import microsandbox as ms + import microsandbox # noqa: F401 except ImportError: return { "backend": self.name, @@ -120,17 +160,13 @@ class MicroVMSandbox(Sandbox): "isolated": True, "error": "未安装 microsandbox(pip install 'agentlab-backend[microvm]')", } - installed = False - try: - installed = bool(ms.is_installed()) - except Exception: # noqa: BLE001 - pass + reason = self.unavailable_reason() cached = self.image_ready() return { "backend": self.name, - "available": installed, + "available": not reason, "isolated": True, - "isolation": "microVM:独立内核 + 独立内存 + 独立进程表(libkrun / Hypervisor.framework)", + "isolation": "microVM:独立内核 + 独立内存 + 独立进程表(libkrun;Linux 上用 KVM,macOS 上用 Hypervisor.framework)", # 只有 network 不是真的——别的都由虚拟化边界强制执行 "enforced": { "timeout": True, @@ -148,7 +184,7 @@ class MicroVMSandbox(Sandbox): "image": self._image, "image_cached": cached, "live_vms": len(self._leases), - **({} if installed else {"error": "运行时未就绪(await microsandbox.install(),约 50 MB)"}), + **({"error": reason} if reason else {}), **({} if cached else {"first_run_cost": "镜像未缓存,首次启动需要拉取镜像(约 55 秒)"}), } @@ -261,10 +297,8 @@ class MicroVMSandbox(Sandbox): if language not in _LANG: return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") if not self.available(): - return ExecResult( - ok=False, backend=self.name, - error="microVM 运行时未就绪:pip install 'agentlab-backend[microvm]' 后执行一次 install", - ) + reason = self.unavailable_reason() or "运行时未就绪" + return ExecResult(ok=False, backend=self.name, error=f"microVM 不可用:{reason}") ext, cmd = _LANG[language] entry = entry_name(ext) diff --git a/backend/app/sandbox/selftest.py b/backend/app/sandbox/selftest.py new file mode 100644 index 00000000..9be53e70 --- /dev/null +++ b/backend/app/sandbox/selftest.py @@ -0,0 +1,46 @@ +"""microVM 自检:真起一台 VM,跑一行代码。 + +deploy.sh 用它决定要不要把 /dev/kvm 交给容器。设备在、权限也对,不等于 VM 起得来: +Docker 的系统调用过滤、宿主机没开嵌套虚拟化,都可能让启动失败。起不来还把设备交给容器, +auto 就会选中 microVM,之后每次执行代码都失败。 + + python -m app.sandbox.selftest + +成功退出码 0,失败 1。输出一行结论,给人看。 +""" +from __future__ import annotations + +import asyncio +import sys + +from app.sandbox.base import SandboxLimits +from app.sandbox.microvm_sandbox import MicroVMSandbox + +MARK = "agentlab-microvm-ok" + + +async def check() -> tuple[bool, str]: + sandbox = MicroVMSandbox() + if not sandbox.available(): + return False, f"microVM 不可用:{sandbox.unavailable_reason() or '运行时未就绪'}" + try: + result = await sandbox.run( + f"print({MARK!r})", + limits=SandboxLimits(timeout=60, memory_mb=256, cpus=1, network=False), + ) + finally: + await sandbox.close() + if result.ok and MARK in result.stdout: + return True, f"microVM 可用:启动并执行一行代码共用时 {result.duration_ms} ms" + detail = result.error or result.stderr.strip() or f"退出码 {result.exit_code},没有输出" + return False, detail if detail.startswith("microVM") else f"microVM 启动失败:{detail}" + + +def main() -> int: + ok, message = asyncio.run(check()) + print(message) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_microvm_kvm.py b/backend/tests/test_microvm_kvm.py new file mode 100644 index 00000000..451a303a --- /dev/null +++ b/backend/tests/test_microvm_kvm.py @@ -0,0 +1,151 @@ +"""Linux 上 microVM 没有 KVM 就起不来,可用性判断要把这一条算进去。 + +以前 available() 只看运行时装没装。运行时和沙箱镜像打进 Docker 镜像以后,没有 KVM 的机器上 +(Mac 上的 Docker Desktop、没开嵌套虚拟化的云主机)auto 会选中 microVM,每次执行代码都启动失败。 +selftest 是部署脚本用的:真起一台 VM,起得来才把 /dev/kvm 交给容器。 +""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from app.sandbox import manager as mgr +from app.sandbox import microvm_sandbox as mv +from app.sandbox import selftest +from app.sandbox.base import ExecResult +from app.sandbox.local_sandbox import LocalSandbox + + +@pytest.fixture +def linux(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """在 Linux 上、运行时已装好、镜像已缓存;返回假的 /dev/kvm 路径,默认不存在。""" + kvm = tmp_path / "kvm" + monkeypatch.setattr(mv, "_on_linux", lambda: True) + monkeypatch.setattr(mv, "_KVM_DEVICE", kvm) + monkeypatch.setattr(mv.MicroVMSandbox, "_runtime_installed", staticmethod(lambda: True)) + monkeypatch.setattr(mv.MicroVMSandbox, "image_ready", staticmethod(lambda: True)) + return kvm + + +def test_no_kvm_device_means_unavailable(linux: Path) -> None: + assert mv.MicroVMSandbox.available() is False + reason = mv.MicroVMSandbox.unavailable_reason() + assert "/dev/kvm" in reason and "挂进容器" in reason + + +def test_kvm_without_permission_means_unavailable(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: False) + assert mv.MicroVMSandbox.available() is False + assert "kvm 组" in mv.MicroVMSandbox.unavailable_reason() + + +def test_kvm_present_and_writable(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: True) + assert mv.MicroVMSandbox.available() is True + assert mv.MicroVMSandbox.unavailable_reason() == "" + + +def test_macos_does_not_need_kvm(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mv, "_on_linux", lambda: False) + assert not linux.exists() + assert mv.kvm_problem() == "" + assert mv.MicroVMSandbox.available() is True + + +def test_runtime_missing_is_reported_before_kvm(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mv.MicroVMSandbox, "_runtime_installed", staticmethod(lambda: False)) + assert "运行时未就绪" in mv.MicroVMSandbox.unavailable_reason() + + +def test_auto_skips_microvm_without_kvm(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # 运行时装了、镜像也缓存了,只差 KVM:auto 不能选它 + monkeypatch.setattr(mgr.BubblewrapSandbox, "available", staticmethod(lambda: False)) + monkeypatch.setattr(mgr.BubblewrapSandbox, "probe_failure", staticmethod(lambda: "")) + monkeypatch.setattr(mgr.SeatbeltSandbox, "available", staticmethod(lambda: False)) + monkeypatch.setattr(mgr.settings, "sandbox_backend", "auto") + manager = mgr.SandboxManager() + assert isinstance(manager._resolve(), LocalSandbox) + # 节点上选 strict 档也点不到它 + assert manager._resolve_named("strict") is None + + +def test_auto_picks_microvm_with_kvm(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: True) + monkeypatch.setattr(mgr.settings, "sandbox_backend", "auto") + manager = mgr.SandboxManager() + assert isinstance(manager._resolve(), mv.MicroVMSandbox) + + +def test_run_says_why_without_booting(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: + async def must_not_boot(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + raise AssertionError("没有 KVM 还去启动 VM") + + monkeypatch.setattr(mv.MicroVMSandbox, "_lease", must_not_boot) + result = asyncio.run(mv.MicroVMSandbox().run("print(1)")) + assert result.ok is False + assert result.error and result.error.startswith("microVM 不可用:") and "/dev/kvm" in result.error + + +def test_health_reports_missing_kvm(linux: Path) -> None: + info = asyncio.run(mv.MicroVMSandbox().health()) + assert info["available"] is False + assert "/dev/kvm" in info["error"] + assert "KVM" in info["isolation"] + + +# ---------------- selftest ---------------- + + +def _fake_run(result: ExecResult, calls: list[str]): + async def run(self, code, **kwargs): # noqa: ANN001, ANN003, ANN202 + calls.append(code) + return result + + return run + + +def test_selftest_without_kvm_does_not_boot(linux: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str]) -> None: + calls: list[str] = [] + monkeypatch.setattr(mv.MicroVMSandbox, "run", _fake_run(ExecResult(), calls)) + assert selftest.main() == 1 + assert calls == [] + out = capsys.readouterr().out.strip() + assert out.startswith("microVM 不可用:") and "/dev/kvm" in out + + +def test_selftest_passes_when_the_vm_prints_the_mark(linux: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str]) -> None: + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: True) + calls: list[str] = [] + ok = ExecResult(stdout=selftest.MARK + "\n", duration_ms=812, backend="microvm") + monkeypatch.setattr(mv.MicroVMSandbox, "run", _fake_run(ok, calls)) + assert selftest.main() == 0 + assert len(calls) == 1 and selftest.MARK in calls[0] + assert "812 ms" in capsys.readouterr().out + + +def test_selftest_reports_boot_failure(linux: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str]) -> None: + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: True) + failed = ExecResult(ok=False, backend="microvm", error="microVM 启动失败:虚拟机没有应答") + monkeypatch.setattr(mv.MicroVMSandbox, "run", _fake_run(failed, [])) + assert selftest.main() == 1 + assert capsys.readouterr().out.strip() == "microVM 启动失败:虚拟机没有应答" + + +def test_selftest_does_not_trust_exit_zero_without_output(linux: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str]) -> None: + # 退出码 0 但什么都没打印:VM 里的代码其实没跑起来,不能当成功 + linux.touch() + monkeypatch.setattr(mv.os, "access", lambda path, mode: True) + monkeypatch.setattr(mv.MicroVMSandbox, "run", _fake_run(ExecResult(backend="microvm"), [])) + assert selftest.main() == 1 + assert capsys.readouterr().out.strip().startswith("microVM 启动失败:") diff --git a/deploy/README.md b/deploy/README.md index acfe7eb2..cf661ecf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,7 +21,8 @@ - **架构要一致**:这个包是 `linux/__AGENTLAB_ARCH__`。在目标机上运行 `uname -m`: `aarch64` 对应 arm64(ARM 单板机要装 64 位系统),`x86_64` 对应 amd64。不一致的镜像跑不起来, deploy.sh 会在导入前就报出来; -- 磁盘:镜像导入后约 0.6~0.8 GB(看 Docker 用的存储方式),另留数据目录的空间; +- 磁盘:镜像导入后约 0.8~1 GB(看 Docker 用的存储方式),另留数据目录的空间; +- 可选:宿主机支持 KVM(有 `/dev/kvm`)时,代码会在 microVM 里执行,隔离最强,见下面的「代码沙箱」; - 当前用户能用 docker(在 `docker` 组里),或者用 `sudo` 运行 deploy.sh。 ## 三步部署 @@ -90,7 +91,7 @@ cp ../agentlab-<旧版本>-linux-__AGENTLAB_ARCH__/agentlab.env . # 沿用原 ## 查看状态和日志 ```bash -./deploy.sh status # 容器状态、健康检查、数据目录、访问地址 +./deploy.sh status # 容器状态、健康检查、数据目录、代码沙箱、访问地址 ./deploy.sh logs # 最后 200 行 ./deploy.sh logs -f # 持续跟随,Ctrl-C 退出 ./deploy.sh logs --tail 1000 @@ -123,26 +124,43 @@ cp ../agentlab-<旧版本>-linux-__AGENTLAB_ARCH__/agentlab.env . # 沿用原 ## 代码沙箱 -工作流里的代码节点、界面上的沙箱都在服务端执行代码。容器里能用哪种隔离,实测结果如下: +工作流里的代码节点、界面上的沙箱都在服务端执行代码。AgentLab 按下表从上往下,挑第一种能用的: -| 容器权限 | 实际使用的沙箱 | 隔离效果 | +| 沙箱 | 条件 | 隔离效果 | | --- | --- | --- | -| 默认(`ENABLE_BWRAP=0`) | local:容器里的普通子进程 | 隔离边界只有容器本身。代码伤不到宿主机,但和 AgentLab 同一个容器、同一个用户,读得到数据目录里的数据库和加密密钥、读得到传进容器的 API Key、能联网 | -| `ENABLE_BWRAP=1` | bubblewrap | 代码看不到数据目录、看不到别的进程,默认断网 | +| microVM | 宿主机有 KVM(默认 `ENABLE_MICROVM=auto`,自动检测) | 每段代码在独立内核的虚拟机里执行:看不到数据目录、密钥和别的进程,内存上限真正生效 | +| bubblewrap | `ENABLE_BWRAP=1` | 共享内核的隔离环境:看不到数据目录和别的进程,默认断网 | +| local | 前两种都用不了 | 容器里的普通子进程。隔离边界只有容器本身:代码伤不到宿主机,但和 AgentLab 同一个容器、同一个用户,读得到数据目录里的数据库和加密密钥、读得到传进容器的 API Key、能联网 | -默认权限下 bubblewrap 无法创建命名空间(Docker 默认的系统调用过滤不允许),AgentLab 会自动退回 local, -「设置 → 运行环境」里的「选择原因」会写明。只运行你信得过的工作流;完全不需要执行代码的话, -在 `agentlab.env` 里设 `AGENTLAB_SANDBOX_BACKEND=off`。 +实际用的是哪一种,deploy.sh 部署完会打印「代码沙箱:……」,`./deploy.sh status` 和界面「设置 → 运行环境」里 +也看得到。只运行你信得过的工作流;完全不需要执行代码的话,在 `agentlab.env` 里设 `AGENTLAB_SANDBOX_BACKEND=off`。 -要用 bubblewrap:把 `agentlab.env` 里的 `ENABLE_BWRAP` 改成 `1`,运行 `./deploy.sh install`。 +### microVM + +要宿主机是支持 KVM 的 Linux:物理机、树莓派 5 这类 64 位 ARM 单板机、开了嵌套虚拟化的云主机。 +Mac、Windows 上的 Docker Desktop 和多数云主机没有 KVM,用不了。检查:`ls -l /dev/kvm` 能看到这个设备就行。 + +运行时和沙箱镜像(`python:3.12-slim`)已经打在镜像里,部署时不用联网。`./deploy.sh install` 会这样处理: + +1. 本机没有 `/dev/kvm`:不启用,照常部署; +2. 有 `/dev/kvm`:先用一个临时容器真起一台 microVM、执行一行代码。起得来,就给正式容器加上 + `--device /dev/kvm` 和 `/dev/kvm` 所属的组(容器里的服务以 uid 10001 运行,要靠这个组读写它); + 起不来,打印原因,照常部署但不启用。 + +`agentlab.env` 里的 `ENABLE_MICROVM`:`auto` 按上面处理(默认),`1` 起不来就报错停下,`0` 不启用。 + +已知缺口:代码节点关掉网络时,HTTP/HTTPS 和域名解析都会断,但 UDP/53 仍然放行,存在经 DNS 外泄数据的可能。 + +### bubblewrap + +把 `agentlab.env` 里的 `ENABLE_BWRAP` 改成 `1`,运行 `./deploy.sh install`。microVM 已经启用时用不上它。 +Docker 默认权限下 bubblewrap 无法创建命名空间(默认的系统调用过滤不允许),所以 deploy.sh 会给容器加 `--security-opt seccomp=unconfined`、`--security-opt systempaths=unconfined`、 `--security-opt apparmor=unconfined`。代价是整个容器少了系统调用过滤和对 `/proc`、`/sys` 部分路径的遮蔽, 内核暴露给容器的面变大(AgentLab 自己仍以非 root 运行)。这组权限在 Docker Desktop(arm64)上实测生效。 Ubuntu 24.04 默认还限制非特权 user namespace:开了之后「设置 → 运行环境」里仍显示 local 的话, 要在宿主机上执行 `sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0`,这会放宽整台机器的限制,自行权衡。 -microVM(独立内核)需要 `/dev/kvm` 和额外下载的运行时与镜像,离线包默认不支持。 - ## 常见问题 **端口被占用。** deploy.sh 报「端口 8000 已被别的程序占用」,或 docker 报 `port is already allocated` / @@ -165,3 +183,13 @@ microVM(独立内核)需要 `/dev/kvm` 和额外下载的运行时与镜像 - `agentlab.env` 里的值带了引号、或者注释写在了值后面:去掉。 **`sha256sum -c` 报 image.tar FAILED。** 包在拷贝中损坏了,重新拷一份。 + +**有 `/dev/kvm`,microVM 却没启用。** deploy.sh 会打印试启动失败的原因。常见的几种: + +- 权限类报错(`Permission denied`、`Operation not permitted`):先确认 `ls -l /dev/kvm` 的属组是 `kvm` 之类的普通组。 + 仍然不行的话,可能是 Docker 默认的系统调用过滤拦住了,可以试 `ENABLE_BWRAP=1`(它会放开这项过滤)再 `./deploy.sh install`; +- 云主机上报虚拟化相关的错:机型不支持或没打开嵌套虚拟化,这种情况用不了 microVM; +- 想让它起不来就停下、而不是悄悄不用:把 `ENABLE_MICROVM` 设成 `1`。 + +**启用 microVM 后,机器重启时容器没起来。** 容器启动时要用到 `/dev/kvm`,开机时 kvm 模块没加载, +Docker 就启动不了这个容器。`ls -l /dev/kvm` 确认设备在,再运行 `./deploy.sh install`,它会重新检测。 diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 7c146678..68e50790 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -5,7 +5,7 @@ # - 值不要加引号,引号会原样变成值的一部分; # - 注释只能单独成行,不能写在值后面。 # -# 前四项(HOST_PORT、BIND_ADDR、DATA_DIR、CONTAINER_NAME)和 ENABLE_BWRAP 是给 deploy.sh 用的; +# 前四项(HOST_PORT、BIND_ADDR、DATA_DIR、CONTAINER_NAME)和 ENABLE_MICROVM、ENABLE_BWRAP 是给 deploy.sh 用的; # 其余的变量原样传进容器,给 AgentLab 自己用。 # ---- 访问 ---- @@ -46,16 +46,30 @@ CONTAINER_NAME=agentlab # NO_PROXY=localhost,127.0.0.1 # ---- 代码沙箱 ---- -# auto:按 microvm → bubblewrap → local 挑第一个能用的。可选值 auto | bubblewrap | local | off -# -# 容器里实测(Docker 默认权限):bubblewrap 无法创建命名空间,auto 自动退回 local,也就是在容器里 -# 起一个普通子进程执行代码。这时隔离的边界只有容器本身:代码伤不到宿主机,但和 AgentLab 在同一个容器、 -# 同一个用户下,读得到数据目录里的数据库和加密密钥、读得到传进容器的 API Key、能联网。 -# 只运行你信得过的工作流;不需要执行代码就设成 off。 +# auto:按 microvm → bubblewrap → local 挑第一个能用的。可选值 auto | microvm | bubblewrap | local | off +# microvm 每段代码在独立内核的虚拟机里执行,看不到容器里的数据目录、密钥和别的进程,内存上限真正生效。 +# 隔离最强,但要宿主机有 KVM,见下面的 ENABLE_MICROVM。 +# bubblewrap 共享内核的隔离环境:看不到数据目录和别的进程,默认断网。要额外的容器权限,见下面的 ENABLE_BWRAP。 +# local 前两种都用不了时的退路:在容器里起一个普通子进程执行代码。隔离的边界只有容器本身, +# 代码伤不到宿主机,但和 AgentLab 在同一个容器、同一个用户下,读得到数据目录里的数据库和 +# 加密密钥、读得到传进容器的 API Key、能联网。只运行你信得过的工作流。 +# off 不执行代码。 +# 实际用的是哪一种,deploy.sh 部署完会打印出来,./deploy.sh status 和界面「设置 → 运行环境」里也看得到。 AGENTLAB_SANDBOX_BACKEND=auto # -# 想让代码在 bubblewrap 里执行(看不到数据目录、看不到别的进程、默认断网),把下面改成 1, -# 然后运行 ./deploy.sh install。deploy.sh 会给容器加三项权限: +# microVM 要宿主机是支持 KVM 的 Linux:物理机、树莓派 5 这类 64 位 ARM 单板机、开了嵌套虚拟化的云主机。 +# Mac、Windows 上的 Docker Desktop 和多数云主机没有 KVM,用不了。 +# auto 本机有 /dev/kvm 时,先用一个临时容器试着启动一台 microVM,起得来才启用;起不来照常部署, +# 只是不用 microVM(默认) +# 1 必须启用,启动不了就报错停下 +# 0 不启用 +# 启用后 deploy.sh 给容器加上 --device /dev/kvm,以及 /dev/kvm 所属的组(容器里的服务以 uid 10001 运行, +# 要靠这个组读写 /dev/kvm)。运行时和沙箱镜像(python:3.12-slim)已经打在镜像里,部署时不用联网。 +# 已知缺口:代码节点关掉网络时,HTTP/HTTPS 和域名解析都会断,但 UDP/53 仍然放行,存在经 DNS 外泄数据的可能。 +ENABLE_MICROVM=auto +# +# 想让代码在 bubblewrap 里执行,把下面改成 1,然后运行 ./deploy.sh install。microVM 已经启用时用不上它。 +# 容器里实测(Docker 默认权限):bubblewrap 无法创建命名空间,所以 deploy.sh 要给容器加三项权限: # --security-opt seccomp=unconfined 放开 Docker 默认的系统调用过滤,才能创建命名空间 # --security-opt systempaths=unconfined 取消对 /proc、/sys 部分路径的遮蔽,才能在沙箱里挂载 /proc # --security-opt apparmor=unconfined Ubuntu 上 Docker 默认的 AppArmor 配置不允许沙箱里的挂载 @@ -65,5 +79,3 @@ AGENTLAB_SANDBOX_BACKEND=auto # sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 # 这一步会放宽整台机器的限制,自行权衡。 ENABLE_BWRAP=0 -# -# microVM(独立内核)需要 /dev/kvm 和额外下载的运行时与镜像,离线包默认不支持。 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 753a7523..144a55a9 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -21,10 +21,11 @@ usage() { install 部署(默认):导入镜像、创建数据目录、启动容器,等健康检查通过后打印访问地址。 已经有同名容器的,先停掉再换成新的,数据保留。改了 agentlab.env 也用它让配置生效。 + 本机有 KVM 时会先试着启动一台 microVM,起得来才让代码在 microVM 里执行(见 agentlab.env)。 upgrade 用这个包里的镜像替换正在用的容器,数据保留。旧镜像留着,方便回退。 start | stop | restart 启动 / 停止 / 重启容器。 - status 查看容器状态、健康检查、数据目录和访问地址。 + status 查看容器状态、健康检查、数据目录、代码沙箱和访问地址。 logs [-f] [--tail N] 查看日志(默认最后 200 行),-f 持续跟随。 uninstall [--purge-image] @@ -104,6 +105,7 @@ BIND_ADDR="0.0.0.0" DATA_DIR="./data" CONTAINER_NAME="agentlab" ENABLE_BWRAP="0" +ENABLE_MICROVM="auto" read_env() { [ -f "$ENV_FILE" ] || die "找不到 ${ENV_FILE},它应该和 deploy.sh 在同一个目录" @@ -118,7 +120,7 @@ read_env() { key="$(trim "${line%%=*}")" val="${line#*=}" case "$key" in - HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP) + HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP|ENABLE_MICROVM) printf -v "$key" '%s' "$(unquote "$(trim "$val")")" ;; AGENTLAB_DATA_DIR|AGENTLAB_WEB_DIST|AGENTLAB_PORT|AGENTLAB_HOST) die "agentlab.env 第 $n 行:不要设 ${key}。容器里的路径和端口是固定的,改宿主机这边用 DATA_DIR、HOST_PORT、BIND_ADDR" ;; @@ -137,6 +139,7 @@ read_env() { ''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9_.-]*) die "CONTAINER_NAME 只能用字母、数字和 _ . -,并以字母或数字开头:$CONTAINER_NAME" ;; esac case "$ENABLE_BWRAP" in 0|1) ;; *) die "ENABLE_BWRAP 只能是 0 或 1:$ENABLE_BWRAP" ;; esac + case "$ENABLE_MICROVM" in auto|0|1) ;; *) die "ENABLE_MICROVM 只能是 auto、0 或 1:$ENABLE_MICROVM" ;; esac # 相对路径以 deploy.sh 所在的目录为准,和从哪里运行无关 # 下面比较的就是字面上的 ~,不需要展开 @@ -273,6 +276,79 @@ fix_data_owner() { || die "无法修改 $DATA_ABS 的属主" } +# --------------------------------------------------------------------------- +# microVM:本机有 KVM、镜像里带了运行时,就先用一个临时容器真起一台 VM,起得来才把 /dev/kvm +# 交给正式容器。只看设备在不在不够:系统调用过滤、嵌套虚拟化没开,都可能让 VM 起不来。 +# 起不来还把设备交出去,服务端会选中 microVM,之后每次执行代码都失败 +# --------------------------------------------------------------------------- +KVM_OPTS=() +MICROVM_OPTS=() + +kvm_opts() { + # 容器里的服务以 uid 10001 运行,要靠 /dev/kvm 所属的组才能读写它;其他用户本来就能读写的不用加组 + local perm gid + perm="$(ls -lL /dev/kvm | cut -c1-10)" + gid="$(ls -nL /dev/kvm | awk '{print $4}')" + KVM_OPTS=(--device /dev/kvm) + case "$perm" in + ???????rw*) ;; + *) KVM_OPTS+=(--group-add "$gid") ;; + esac +} + +microvm_selftest() { + # 和正式容器一样的配置和权限,只是不挂数据目录、不开端口。只取最后一行:结论,或者报错的最后一句 + local extra=() + [ "$ENABLE_BWRAP" = 1 ] && extra=("${BWRAP_OPTS[@]}") + docker run --rm \ + --env-file "$ENV_FILE" \ + -e AGENTLAB_MICROVM_BOOT_TIMEOUT=120 \ + "${KVM_OPTS[@]}" ${extra[@]+"${extra[@]}"} \ + --entrypoint python "$IMAGE" -m app.sandbox.selftest 2>&1 | tail -n 1 +} + +decide_microvm() { + MICROVM_OPTS=() + [ "$ENABLE_MICROVM" != 0 ] || return 0 + local baked out + baked="$(docker image inspect -f '{{index .Config.Labels "agentlab.microvm"}}' "$IMAGE" 2>/dev/null || true)" + if [ "$baked" != 1 ]; then + [ "$ENABLE_MICROVM" = auto ] || die "ENABLE_MICROVM=1,但这个包打的时候没有带 microVM 运行时(用了 --no-microvm)。 +换一个默认方式打出来的包,或者把 ENABLE_MICROVM 改成 auto" + return 0 + fi + if [ ! -e /dev/kvm ]; then + if [ "$ENABLE_MICROVM" = 1 ]; then + die "ENABLE_MICROVM=1,但这台机器没有 /dev/kvm,启动不了 microVM。 + - 物理机:确认 CPU 支持虚拟化、BIOS 里已打开,并已加载 kvm 模块(ls -l /dev/kvm 能看到它); + - 云主机:要选支持嵌套虚拟化的机型并打开这项功能; + - 不需要 microVM 的话,把 ENABLE_MICROVM 改成 auto 或 0。" + fi + say "这台机器没有 KVM(/dev/kvm),不启用 microVM" + return 0 + fi + kvm_opts + say "检测到 /dev/kvm,试着启动一台 microVM(第一次要十几秒到一分钟)" + if out="$(microvm_selftest)"; then + MICROVM_OPTS=("${KVM_OPTS[@]}") + say "$out" + return 0 + fi + [ "$ENABLE_MICROVM" = auto ] || die "ENABLE_MICROVM=1,但 microVM 启动不了:${out:-没有输出} +常见原因见 README 的常见问题。不需要 microVM 的话,把 ENABLE_MICROVM 改成 auto 或 0" + warn "microVM 启动不了,这次不启用,代码仍按原来的方式执行。原因:${out:-没有输出}" +} + +# 服务端实际选中的沙箱。镜像里没有 curl,用 python 从容器里问 +sandbox_summary() { + docker exec "$CONTAINER_NAME" python -c ' +import json, urllib.request as u +r = u.build_opener(u.ProxyHandler({})).open("http://127.0.0.1:8000/api/sandbox/health", timeout=5) +d = json.load(r) +print(d.get("selected_because") or d.get("backend") or "未知") +' 2>/dev/null || echo "查不到" +} + # --------------------------------------------------------------------------- # 容器 # --------------------------------------------------------------------------- @@ -318,6 +394,7 @@ run_container() { -v "$DATA_ABS:/data" \ --log-opt max-size=10m --log-opt max-file=3 \ ${extra[@]+"${extra[@]}"} \ + ${MICROVM_OPTS[@]+"${MICROVM_OPTS[@]}"} \ "$IMAGE" >/dev/null } @@ -397,6 +474,7 @@ deploy_container() { if wait_healthy "$CONTAINER_NAME"; then [ -z "$had_old" ] || docker rm "$prev" >/dev/null 2>&1 || true say "AgentLab $VERSION 已就绪,数据目录 $DATA_ABS" + say "代码沙箱:$(sandbox_summary)" print_access return 0 fi @@ -420,6 +498,7 @@ cmd_install() { prepare_data check_existing fix_data_owner + decide_microvm deploy_container } @@ -440,6 +519,7 @@ cmd_upgrade() { prepare_data check_existing fix_data_owner + decide_microvm deploy_container if [ "$old_image" != "$IMAGE" ] && docker image inspect "$old_image" >/dev/null 2>&1; then echo "旧镜像 $old_image 还留着,方便回退。确认新版本没问题后可以删:docker rmi $old_image" @@ -492,6 +572,7 @@ cmd_status() { echo "镜像 $image" echo "端口 $(docker port "$CONTAINER_NAME" 8000/tcp 2>/dev/null | tr '\n' ' ')" echo "数据目录 ${src:-(未挂载)}" + [ "$state" != running ] || echo "代码沙箱 $(sandbox_summary)" echo "这个包 $IMAGE" if [ "$state" = running ]; then print_access; fi } diff --git a/scripts/package-docker.sh b/scripts/package-docker.sh index 290e285f..8b23150b 100755 --- a/scripts/package-docker.sh +++ b/scripts/package-docker.sh @@ -5,17 +5,19 @@ set -euo pipefail usage() { cat <<'USAGE' -用法:./scripts/package-docker.sh [--platform linux/arm64|linux/amd64] [--version <版本>] [--out <目录>] [--gzip] +用法:./scripts/package-docker.sh [--platform linux/arm64|linux/amd64] [--version <版本>] [--out <目录>] [--gzip] [--no-microvm] --platform <平台> 目标机的平台。默认按本机架构取 linux/。64 位系统的 ARM 单板机是 linux/arm64, 普通 PC 和服务器是 linux/amd64。和本机架构不同时 docker 用模拟构建,会慢很多。 --version <版本> 镜像标签和包名里的版本。默认 git describe --tags --always --dirty,拿不到就用短哈希加日期。 --out <目录> 产物放哪里,默认仓库下的 release/。 --gzip 另外再产出一份 .tar.gz。docker 用 containerd 镜像存储时镜像层本身已经压缩过,.tar.gz 小不了多少。 + --no-microvm 不在镜像里带 microVM 运行时和沙箱镜像,镜像小约 150 MB。目标机没有 KVM 时用不上它们。 + 默认带上:目标机有 KVM 时,deploy.sh 会自动启用 microVM,而且不用联网。 -h, --help 显示这段说明。 产物:/agentlab-<版本>-linux-.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS。 -构建需要联网(拉基础镜像、装依赖);部署不需要。镜像只留在本机,不会推到任何仓库。 +构建需要联网(拉基础镜像、装依赖、拉 microVM 的沙箱镜像);部署不需要。镜像只留在本机,不会推到任何仓库。 USAGE } @@ -25,6 +27,7 @@ PLATFORM="" VERSION="" OUT="$ROOT/release" GZIP="" +WITH_MICROVM=1 need_value() { [ $# -ge 2 ] && [ -n "$2" ] || { echo "$1 后面要跟一个值"; echo; usage; exit 2; }; } while [ $# -gt 0 ]; do case "$1" in @@ -35,6 +38,7 @@ while [ $# -gt 0 ]; do --out) need_value "$@"; OUT="$2"; shift ;; --out=*) OUT="${1#--out=}" ;; --gzip) GZIP=1 ;; + --no-microvm) WITH_MICROVM=0 ;; -h|--help) usage; exit 0 ;; *) echo "认不出的参数:$1"; echo; usage; exit 2 ;; esac @@ -86,7 +90,8 @@ OUT="$(cd "$OUT" && pwd)" STAGE="$OUT/$NAME" TAR="$OUT/$NAME.tar" -echo "==> 版本 $VERSION · 平台 $PLATFORM · 产物 $TAR" +if [ "$WITH_MICROVM" = 1 ]; then MICROVM_NOTE="带 microVM"; else MICROVM_NOTE="不带 microVM"; fi +echo "==> 版本 $VERSION · 平台 $PLATFORM · $MICROVM_NOTE · 产物 $TAR" # ---- 1. 构建 ---- # 不带 provenance / SBOM:它们会让导出的镜像多出几份附加清单,老版本的 docker load 认不全 @@ -95,6 +100,7 @@ docker buildx build \ --platform "$PLATFORM" \ --provenance=false --sbom=false \ --build-arg VERSION="$VERSION" \ + --build-arg WITH_MICROVM="$WITH_MICROVM" \ --load \ -t "$IMAGE" \ "$ROOT" @@ -150,7 +156,7 @@ echo echo "完成:" echo " $TAR ($(size "$TAR"))" [ -z "$GZIP" ] || echo " $TAR.gz ($(size "$TAR.gz"))" -echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库)" +echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库),$MICROVM_NOTE" echo echo "下一步:" echo " 1. 把 $NAME.tar 拷到目标机(scp、U 盘都行)"