Files
AgentLab/backend/app/sandbox/microvm_sandbox.py
T
ThearchyHelios 78f24dfe3f 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 同步
2026-09-30 11:19:25 +08:00

428 lines
18 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.
from __future__ import annotations
import asyncio
import logging
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.core.config import sanitize_session, settings
from app.core.errors import describe_exception, raw
from app.sandbox.base import (
ENTRY_PREFIX,
ExecResult,
Sandbox,
SandboxLimits,
entry_name,
truncate,
)
logger = logging.getLogger(__name__)
_WORKDIR = "/workspace"
# 各语言的入口脚本后缀与执行命令。文件名在运行时生成(见 entry_name),
# 不能写死 —— 同会话并发执行会互相覆盖。
_LANG = {
"python": (".py", ["python", "-u"]),
"bash": (".sh", ["/bin/bash"]),
"sh": (".sh", ["/bin/sh"]),
"node": (".js", ["node"]),
"javascript": (".js", ["node"]),
}
_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
class _Lease:
"""一台长驻的 microVM。
SDK 的 create() 直接返回已启动的 Sandbox,没有 handle/connect 两层——
这点是实测确认的,早先按别的 SDK 惯例推断的写法在这里是错的。
"""
vm: Any
network: bool
memory_mb: int
cpus: int
last_used: float = field(default_factory=time.monotonic)
# 这台是因为限额变更重建出来的,也就是说上一次的会话文件已经没了
was_reset: bool = False
class MicroVMSandbox(Sandbox):
"""硬件级隔离:每个会话一台独立内核的 microVM(libkrun + Hypervisor.framework)。
和 Seatbelt 的差别不是"更严格的访问控制",而是换了一类边界。本机实测:
宿主是 Darwin 27.0.0,VM 里是 Linux 6.12.99(libkrunfw 编译),PID 1 是
init.krun,/Users 在 VM 里根本不存在,`free` 只看得见 512MB。
因此 Seatbelt 上那个最难受的缺口在这里是真的没了:申请 900MB 会被内核
OOM killer 杀掉(进程退出码为负),而 VM 本身照常存活——macOS 上
RLIMIT_AS 形同虚设的问题不复存在。
但有一处**必须说清楚的残留缺口**:网络关不干净。limits.network=False 时
HTTP/HTTPS、域名解析都会断,可 UDP/53 拦不住——实测手写 DNS 包仍能拿到
真实响应。试过 default_egress=DENY、Rule.deny_dns()、显式 deny UDP、
max_connections=0,UDP 那条都堵不上(deny_dns 生成的规则只针对宿主,
而 DNS 出口在 microsandbox 的网络栈里是硬编码放行的)。也就是说
**DNS 隧道外泄通道始终是开的**。防"代码意外联网装包"够用,防"恶意代码
偷数据"不够——health() 里如实标成部分生效,不写 True。
成本也照实说:运行时约 50MB,OCI 镜像首次拉取本机实测 54.5s;之后热启动
0.19s、VM 内执行 7~30ms。所以贵的是第一次,不是每一次。
"""
name = "microvm"
def __init__(self) -> None:
self._leases: dict[str, _Lease] = {}
self._lock = asyncio.Lock()
self._image = settings.microvm_image
# ---------------- 可用性 ----------------
@staticmethod
def _runtime_installed() -> bool:
try:
import microsandbox as ms
except ImportError:
return False
try:
return bool(ms.is_installed())
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:
"""镜像是否已经拉到本地。
auto 模式拿这个做判断:镜像没缓存时第一次跑代码要等 50 秒以上,
与其让人卡在那儿,不如安静地先用 Seatbelt——显式选 strict 的人
另说,那是他自己要的硬隔离,等就等。
"""
try:
return _MANIFEST_CACHE.is_dir() and any(_MANIFEST_CACHE.iterdir())
except OSError:
return False
async def health(self) -> dict[str, Any]:
try:
import microsandbox # noqa: F401
except ImportError:
return {
"backend": self.name,
"available": False,
"isolated": True,
"error": "未安装 microsandbox(pip install 'agentlab-backend[microvm]')",
}
reason = self.unavailable_reason()
cached = self.image_ready()
return {
"backend": self.name,
"available": not reason,
"isolated": True,
"isolation": "microVM:独立内核 + 独立内存 + 独立进程表(libkrun;Linux 上用 KVM,macOS 上用 Hypervisor.framework)",
# 只有 network 不是真的——别的都由虚拟化边界强制执行
"enforced": {
"timeout": True,
"cpu": True,
"memory": True,
"fsize": True,
"pid": True,
"network": "partial",
},
"limits_note": (
"内存、CPU、进程表由内核边界强制限制(超限进程会被 OOM killer 终止,VM 保持运行)。"
"网络隔离仅部分生效:可切断 HTTP/HTTPS 与域名解析,但无法拦截 UDP/53,"
"存在经 DNS 隧道外泄数据的风险。"
),
"image": self._image,
"image_cached": cached,
"live_vms": len(self._leases),
**({"error": reason} if reason else {}),
**({} if cached else {"first_run_cost": "镜像未缓存,首次启动需要拉取镜像(约 55 秒)"}),
}
# ---------------- 会话(长驻 VM)----------------
def _vm_name(self, session_id: str) -> str:
# SDK 要求首字符是字母数字,其余只能是字母数字/点/横线/下划线。
# 这里直接用共用的净化规则(比 SDK 的要求更严,不放行点号),
# 让 VM 名和别处的会话目录名保持一致。
return f"agentlab-{sanitize_session(session_id)}"
async def _lease(self, session_id: str, limits: SandboxLimits) -> _Lease:
"""拿到该会话的 VM。限额变了就重建,否则复用(热复用约 7ms)。"""
import microsandbox as ms
cpus = max(1, int(limits.cpus))
async with self._lock:
reset = False
lease = self._leases.get(session_id)
if lease is not None:
if (lease.network == limits.network
and lease.memory_mb == limits.memory_mb
and lease.cpus == cpus):
lease.last_used = time.monotonic()
return lease
# 限额变了:VM 的内存/CPU 是启动时定死的,只能销毁重建。
# 但重建等于把会话工作区清空——同一个 run 里两个代码节点各填了
# 不同的 memory_mb 就会触发,而调用方对此毫无察觉。标记出来。
await self._destroy(session_id)
reset = True
# network 必须是 Network 实例(传 bool 会被 SDK 拒掉)
network = ms.Network.allow_all() if limits.network else ms.Network.none()
vm = await asyncio.wait_for(
ms.Sandbox.create(
self._vm_name(session_id),
image=ms.Image.oci(self._image),
cpus=cpus,
memory=limits.memory_mb,
network=network,
),
timeout=settings.microvm_boot_timeout,
)
try:
await vm.fs.mkdir(_WORKDIR)
except Exception: # noqa: BLE001 - 已存在就算了
pass
lease = _Lease(vm=vm, network=limits.network,
memory_mb=limits.memory_mb, cpus=cpus)
lease.was_reset = reset
self._leases[session_id] = lease
return lease
async def _destroy(self, session_id: str) -> None:
lease = self._leases.pop(session_id, None)
if lease is None:
return
try:
await lease.vm.destroy(force=True)
except Exception: # noqa: BLE001 - 已经没了就算
pass
async def _kill_leftovers(self, session_id: str, entry: str) -> bool:
"""超时后在 VM 内把跑飞的进程杀掉,保住会话工作区。
返回 True 表示 VM 还能用;False 表示没收拾干净,调用方该销毁重建。
"""
lease = self._leases.get(session_id)
if lease is None:
return False
try:
# 只杀这次的入口脚本引出来的进程——入口名带唯一后缀,
# 不会误伤同会话里别的并发执行
await asyncio.wait_for(
lease.vm.shell(f"pkill -9 -f {entry} || true", timeout=10),
timeout=15,
)
# 确认 VM 还答应
out = await asyncio.wait_for(
lease.vm.exec("sh", ["-c", "echo alive"], timeout=10), timeout=15
)
return "alive" in str(getattr(out, "stdout_text", "") or "")
except Exception: # noqa: BLE001 - 收拾不动就让调用方销毁
return False
async def _reap_idle(self) -> None:
"""回收闲置 VM:它们各自占着几百 MB 内存,不能一直挂着。"""
cutoff = time.monotonic() - settings.microvm_idle_seconds
stale = [sid for sid, lease in self._leases.items() if lease.last_used < cutoff]
for sid in stale:
await self._destroy(sid)
# ---------------- 执行 ----------------
async def run(
self,
code: str,
*,
language: str = "python",
limits: SandboxLimits | None = None,
session_id: str | None = None,
env: dict[str, str] | None = None,
files: dict[str, str] | None = None,
) -> ExecResult:
import microsandbox as ms
limits = limits or SandboxLimits()
language = (language or "python").lower()
if language not in _LANG:
return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}")
if not self.available():
reason = self.unavailable_reason() or "运行时未就绪"
return ExecResult(ok=False, backend=self.name, error=f"microVM 不可用:{reason}")
ext, cmd = _LANG[language]
entry = entry_name(ext)
sid = session_id or f"tmp{int(time.time() * 1000)}"
ephemeral = session_id is None
started = time.perf_counter()
try:
await self._reap_idle()
lease = await self._lease(sid, limits)
except asyncio.TimeoutError:
return ExecResult(
ok=False, backend=self.name, timed_out=True,
duration_ms=int((time.perf_counter() - started) * 1000),
error=f"microVM 启动超过 {settings.microvm_boot_timeout} 秒"
+ ("" if self.image_ready() else "(镜像未缓存,首次启动需要拉取镜像,约 55 秒)"),
)
except Exception as e: # noqa: BLE001
logger.warning("microVM 启动失败:%s", raw(e))
return ExecResult(
ok=False, backend=self.name,
duration_ms=int((time.perf_counter() - started) * 1000),
error=f"microVM 启动失败:{describe_exception(e)}",
)
try:
payload = dict(files or {})
payload[entry] = code
for rel, content in payload.items():
rel = rel.lstrip("/")
if ".." in rel.split("/"):
return ExecResult(ok=False, backend=self.name, error=f"非法文件路径:{rel}")
data = content.encode()
if len(data) > _MAX_FILE_BYTES:
return ExecResult(ok=False, backend=self.name, error=f"{rel} 超过 8 MB 上限")
target = f"{_WORKDIR}/{rel}"
if "/" in rel:
try:
await lease.vm.fs.mkdir(target.rsplit("/", 1)[0])
except Exception: # noqa: BLE001
pass
await lease.vm.fs.write(target, data)
# cwd / env / timeout 都是 exec 的原生参数,不用再拿 shell 拼
out = await lease.vm.exec(
cmd[0], [*cmd[1:], entry],
cwd=_WORKDIR,
env=({k: str(v) for k, v in env.items()} if env else None),
timeout=limits.timeout,
)
except ms.ExecTimeoutError:
# 超时的 VM 里多半还有进程在烧 CPU,得收拾掉。但**先别销毁整台 VM**:
# 会话契约说同 session 共享文件系统(上一步写的文件下一步要读回来),
# 销毁 VM 等于把工作区连同用户数据一起清空,而另外三个后端超时只
# killpg、工作目录照留。先试着在 VM 里把残留进程杀干净,
# 实在不行才销毁——并且如实告诉调用方会话被重置了。
killed = await self._kill_leftovers(sid, entry)
if not killed:
await self._destroy(sid)
return ExecResult(
ok=False, backend=self.name, timed_out=True, exit_code=124,
duration_ms=int((time.perf_counter() - started) * 1000),
session_reset=not killed,
error=f"执行超过 {limits.timeout} 秒,已终止"
+ ("" if killed else ";VM 已重置,该会话之前写的文件已丢失"),
)
except Exception as e: # noqa: BLE001
logger.warning("microVM 执行中断:%s", raw(e))
return ExecResult(
ok=False, backend=self.name,
duration_ms=int((time.perf_counter() - started) * 1000),
error=f"microVM 中的执行意外中断:{describe_exception(e)}",
)
finally:
if ephemeral:
await self._destroy(sid)
elif sid in self._leases:
# 入口脚本是这次执行的私有临时文件,跑完就收掉,
# 免得会话工作区被历年的 __entry_xxx 堆满
try:
await lease.vm.fs.remove(f"{_WORKDIR}/{entry}")
except Exception: # noqa: BLE001 - 删不掉不影响执行结果
pass
stdout, t1 = truncate(str(getattr(out, "stdout_text", "") or ""), limits.max_output)
stderr, t2 = truncate(str(getattr(out, "stderr_text", "") or ""), limits.max_output)
exit_code = int(getattr(out, "exit_code", 0) or 0)
# 退出码为负 = 进程被信号杀掉(SDK 用负值表示,具体是哪个信号它没给,
# 所以这里不编)。VM 里最常见的原因就是撞了内存上限,而且这种情况
# stdout/stderr 通常都是空的——不解释一句,用户只会看到"失败了"。
error = None
if exit_code < 0 and not stderr.strip():
error = (f"进程被内核终止(退出码 {exit_code}),"
f"最常见的原因是超过了 {limits.memory_mb} MB 内存上限")
written: list[str] = []
if not ephemeral:
try:
entries = await lease.vm.fs.list(_WORKDIR)
# FsEntry 给的是 path(全路径),不是 name —— 取尾段当文件名。
# 早先按 name 取,结果 files_written 里塞的是对象 repr。
names = [str(getattr(e, "path", "") or "").rsplit("/", 1)[-1] for e in entries]
written = [n for n in names if n and not n.startswith(ENTRY_PREFIX)][:50]
except Exception: # noqa: BLE001 - 列目录失败不影响执行结果
written = []
return ExecResult(
ok=exit_code == 0,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
truncated=t1 or t2,
backend=self.name,
duration_ms=int((time.perf_counter() - started) * 1000),
files_written=written,
session_reset=lease.was_reset,
error=error,
)
async def cleanup(self, session_id: str) -> None:
await self._destroy(session_id)
async def close(self) -> None:
for sid in list(self._leases):
await self._destroy(sid)