Docker 镜像内置 microVM:构建时装好运行时、预拉沙箱镜像;deploy.sh 检测到 KVM 先试启动一台,起得来才交给容器;没有 KVM 时服务端不再选 microVM

- microvm_sandbox:Linux 上 available() 要求 /dev/kvm 存在且可读写,不可用时给出具体原因(没装、运行时没装、没有 KVM、没有权限)
- 新增 app.sandbox.selftest:真起一台 VM 执行一行代码,部署脚本用它判断
- Dockerfile:WITH_MICROVM(默认 1)构建期 install 并 pull python:3.12-slim,自带的 msb、libkrunfw 改为链接,镜像约多 150 MB
- deploy.sh:ENABLE_MICROVM=auto|0|1,有 KVM 时用临时容器试启动,成功才加 --device /dev/kvm 和所属组;部署完和 status 打印实际用的沙箱
- package-docker.sh:--no-microvm;部署说明、配置模板、README 同步
This commit is contained in:
ThearchyHelios
2026-09-30 11:17:19 +08:00
parent fec356089c
commit 78f24dfe3f
9 changed files with 440 additions and 51 deletions
+31 -4
View File
@@ -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"]
+7 -3
View File
@@ -67,7 +67,11 @@ http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建
`--platform linux/arm64` 或 `linux/amd64`),连同一键部署脚本、配置模板和部署说明打成
`release/agentlab-<版本>-linux-<arch>.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` 强制指定,
+49 -15
View File
@@ -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)
+46
View File
@@ -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())
+151
View File
@@ -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 启动失败:")
+40 -12
View File
@@ -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`,它会重新检测。
+23 -11
View File
@@ -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 和额外下载的运行时与镜像,离线包默认不支持。
+83 -2
View File
@@ -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
}
+10 -4
View File
@@ -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/<arch>。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 显示这段说明。
产物:<out>/agentlab-<版本>-linux-<arch>.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 盘都行)"