From 9f05178f2b7003a4c2158e3432590066f43faf95 Mon Sep 17 00:00:00 2001 From: host Date: Mon, 26 Oct 2026 10:26:21 +0800 Subject: [PATCH] Docker tasks: --docker-runtime option (runsc support, default off) DockerExecutor gains a runtime parameter (passed as docker --runtime in build_run_command, after --pids-limit); worker CLI gains --docker-runtime, wired through _build_cli_executor. Unset or empty means no --runtime flag, so existing tasks keep docker default (runc); an unregistered runtime fails container start (fail closed), it never silently falls back. Tests: runsc appears in the command, default/empty omit it, CLI wiring passes through. README documents the flag and corrects a stale multi-tenant note (host kernel does not ban userns; bwrap fails inside Docker containers on nested-pidns proc mount). --- cloud/README.md | 8 +++- cloud/sandbox.py | 13 +++++- cloud/tests/test_docker_runtime.py | 70 ++++++++++++++++++++++++++++++ cloud/worker.py | 7 ++- 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 cloud/tests/test_docker_runtime.py diff --git a/cloud/README.md b/cloud/README.md index 289a8ed..4b92002 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -489,14 +489,18 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳 `--cap-drop=ALL --security-opt=no-new-privileges`。codex-home 只播种模板的 config.toml(relay 端点与 provider 名,不含 密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍 - 是 danger-full-access(主机内核禁 user namespace,bwrap 不可 - 用),隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。 + 是 danger-full-access(bwrap 在 Docker 容器内不可用:嵌套 + pidns 下 fresh proc mount 内核 EPERM,宿主级则可用,见下), + 隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。 - 外层加固(内层保持 danger-full-access 时的补偿层): `--docker-security-opt`(可重复,如 `apparmor=docker-codex`, 见 `deploy/apparmor/docker-codex`:在 Docker 默认 profile 基础上禁 raw 网络、ptrace、mount 与危险 capability;注意 它不含 `userns`,即不启用 bwrap 内层沙箱路线); `--docker-pids-limit`(默认 512,防 fork 炸弹); + `--docker-runtime`(不传 = docker 默认 runc;传 `runsc` = + 任务容器走 gVisor syscall 沙箱,需 daemon 已注册该 runtime, + 否则起容器失败;生产切换前须经一次性 worker 模型任务验证); `--egress-proxy-port`(0 = 不启用;生产配 8888):启用后 Docker 任务容器的 HTTP(S) 出口全部走宿主上的 Tinyproxy (见 `deploy/egress/`:FilterDefaultDeny 白名单,只放行 diff --git a/cloud/sandbox.py b/cloud/sandbox.py index 5bed605..53bb4b0 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -265,7 +265,7 @@ class DockerExecutor(Executor): user="1000:1000", worktrees_root=None, env=None, env_key="XCM_API_KEY", timeout=None, extra_mounts=None, model=None, security_opts=None, pids_limit=None, - extra_env=None): + runtime=None, extra_env=None): if host_py_path is None: here = os.path.dirname(os.path.abspath(__file__)) host_py_path = os.path.join(os.path.dirname(here), "host.py") @@ -294,6 +294,13 @@ class DockerExecutor(Executor): # 的 -e 名方式)。 self.security_opts = list(security_opts or []) self.pids_limit = pids_limit + # runtime:docker --runtime(如 runsc,gVisor 沙箱)。 + # None 或空串 = 不传 --runtime,走 docker 默认(runc), + # 现有任务行为不变;显式值由 docker 在起容器时校验, + # 未注册的 runtime 会起容器失败(fail closed)。 + self.runtime = runtime.strip() if isinstance(runtime, str) else runtime + if not self.runtime: + self.runtime = None self.extra_env = dict(extra_env or {}) @staticmethod @@ -387,7 +394,7 @@ class DockerExecutor(Executor): 顺序固定:docker run --rm --name <容器名> --user <用户> --cpus <核数> --memory <内存> [--cap-drop=ALL --security-opt=no-new-privileges] [--security-opt <自定义>] - [--pids-limit ] [--network <网络>] + [--pids-limit ] [--runtime ] [--network <网络>] -v :/work -v <代码目录>:/app:ro -w /work [-e ] [-e ...] <镜像> <容器内命令...>。 -e 只传名字,不带 =值(env_key);extra_env 的 -e 带值, @@ -415,6 +422,8 @@ class DockerExecutor(Executor): cmd += ["--security-opt", opt] if self.pids_limit is not None: cmd += ["--pids-limit", str(self.pids_limit)] + if self.runtime: + cmd += ["--runtime", self.runtime] if network: cmd += ["--network", network] cmd += ["-v", "%s:/work" % self._worktree_for(task), diff --git a/cloud/tests/test_docker_runtime.py b/cloud/tests/test_docker_runtime.py new file mode 100644 index 0000000..1a6b1d0 --- /dev/null +++ b/cloud/tests/test_docker_runtime.py @@ -0,0 +1,70 @@ +"""Docker runtime 单测:--runtime=runsc 接入。 + +只用假 runner,不跑真实 Docker。覆盖:显式 runtime 进命令 +(且在 --pids-limit 之后)、默认/空串不传 --runtime、 +worker CLI --docker-runtime 透传到 DockerExecutor。 +""" + +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +try: + from cloud.sandbox import DockerExecutor + from cloud.worker import _build_cli_executor +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.sandbox import DockerExecutor + from cloud.worker import _build_cli_executor + + +def _executor(**kwargs): + def fake(cmd, timeout=None): + return SimpleNamespace(returncode=0, stdout="", stderr="") + kwargs.setdefault("env", {"XCM_API_KEY": "test-placeholder"}) + kwargs.setdefault("runner", fake) + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + return DockerExecutor("/fake/host.py", **kwargs) + + +def _cmd(ex): + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + return ex.build_run_command( + {"id": "t1", "worktree": "/tmp/wt"}, ["echo", "hi"]) + + +class DockerRuntimeTestCase(unittest.TestCase): + def test_runsc_appears_in_command(self): + cmd = _cmd(_executor(runtime="runsc", pids_limit=512)) + self.assertIn("--runtime", cmd) + self.assertEqual(cmd[cmd.index("--runtime") + 1], "runsc") + # 顺序:--runtime 在 --pids-limit 之后。 + self.assertLess(cmd.index("--pids-limit"), cmd.index("--runtime")) + + def test_default_and_empty_omit_runtime(self): + self.assertNotIn("--runtime", _cmd(_executor())) + self.assertNotIn("--runtime", _cmd(_executor(runtime=""))) + self.assertNotIn("--runtime", _cmd(_executor(runtime=" "))) + + def test_cli_wiring(self): + args = SimpleNamespace( + executor="docker", config=None, + docker_image="codex-app-server:latest", + docker_cpus="2", docker_memory="2g", docker_user="1000:1000", + docker_worktrees_root=None, env_key=None, codex_config=None, + docker_timeout=None, docker_extra_mounts=None, + docker_security_opt=None, docker_pids_limit=512, + docker_runtime="runsc") + ex = _build_cli_executor(args, "/fake/host.py") + self.assertIsInstance(ex, DockerExecutor) + self.assertEqual(ex.runtime, "runsc") + args2 = SimpleNamespace(**{**vars(args), "docker_runtime": None}) + self.assertIsNone(_build_cli_executor(args2, "/fake/host.py").runtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/worker.py b/cloud/worker.py index ace43db..7f67f86 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -1792,7 +1792,8 @@ def _build_cli_executor(args, host_py): timeout=args.docker_timeout, extra_mounts=args.docker_extra_mounts, security_opts=args.docker_security_opt, - pids_limit=args.docker_pids_limit) + pids_limit=args.docker_pids_limit, + runtime=getattr(args, "docker_runtime", None)) def main(argv=None): @@ -1877,6 +1878,10 @@ def main(argv=None): parser.add_argument("--docker-pids-limit", type=int, default=512, help="docker --pids-limit(默认 512,防 fork 炸弹;" "codex 工具链正常用量远低于此)") + parser.add_argument("--docker-runtime", default=None, + help="docker --runtime(不传 = docker 默认 runc;" + "传 runsc = 任务容器走 gVisor 沙箱,需 daemon" + "已注册该 runtime,否则起容器失败)") parser.add_argument("--egress-proxy-port", type=int, default=0, help="egress 过滤代理端口(0 = 不启用;生产配" " 8888):启用后 Docker 任务容器的 HTTP(S)"