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).
This commit is contained in:
host committed 2026-10-26 10:26:21 +08:00
1 parent b31e9365de
commit 9f05178f2b
4 files changed
+93 -5

No files matched your search

+6 -2
View File
@@ -489,14 +489,18 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home `--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
只播种模板的 config.toml(relay 端点与 provider 名,不含 只播种模板的 config.toml(relay 端点与 provider 名,不含
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍 密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
是 danger-full-access(主机内核禁 user namespace,bwrap 不可 是 danger-full-access(bwrap 在 Docker 容器内不可用:嵌套
用),隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。 pidns 下 fresh proc mount 内核 EPERM,宿主级则可用,见下),
隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。
- 外层加固(内层保持 danger-full-access 时的补偿层): - 外层加固(内层保持 danger-full-access 时的补偿层):
`--docker-security-opt`(可重复,如 `apparmor=docker-codex`, `--docker-security-opt`(可重复,如 `apparmor=docker-codex`,
见 `deploy/apparmor/docker-codex`:在 Docker 默认 profile 见 `deploy/apparmor/docker-codex`:在 Docker 默认 profile
基础上禁 raw 网络、ptrace、mount 与危险 capability;注意 基础上禁 raw 网络、ptrace、mount 与危险 capability;注意
它不含 `userns`,即不启用 bwrap 内层沙箱路线); 它不含 `userns`,即不启用 bwrap 内层沙箱路线);
`--docker-pids-limit`(默认 512,防 fork 炸弹); `--docker-pids-limit`(默认 512,防 fork 炸弹);
`--docker-runtime`(不传 = docker 默认 runc;传 `runsc` =
任务容器走 gVisor syscall 沙箱,需 daemon 已注册该 runtime,
否则起容器失败;生产切换前须经一次性 worker 模型任务验证);
`--egress-proxy-port`(0 = 不启用;生产配 8888):启用后 `--egress-proxy-port`(0 = 不启用;生产配 8888):启用后
Docker 任务容器的 HTTP(S) 出口全部走宿主上的 Tinyproxy Docker 任务容器的 HTTP(S) 出口全部走宿主上的 Tinyproxy
(见 `deploy/egress/`:FilterDefaultDeny 白名单,只放行 (见 `deploy/egress/`:FilterDefaultDeny 白名单,只放行
+11 -2
View File
@@ -265,7 +265,7 @@ class DockerExecutor(Executor):
user="1000:1000", worktrees_root=None, env=None, user="1000:1000", worktrees_root=None, env=None,
env_key="XCM_API_KEY", timeout=None, extra_mounts=None, env_key="XCM_API_KEY", timeout=None, extra_mounts=None,
model=None, security_opts=None, pids_limit=None, model=None, security_opts=None, pids_limit=None,
extra_env=None): runtime=None, extra_env=None):
if host_py_path is None: if host_py_path is None:
here = os.path.dirname(os.path.abspath(__file__)) here = os.path.dirname(os.path.abspath(__file__))
host_py_path = os.path.join(os.path.dirname(here), "host.py") host_py_path = os.path.join(os.path.dirname(here), "host.py")
@@ -294,6 +294,13 @@ class DockerExecutor(Executor):
# 的 -e 名方式)。 # 的 -e 名方式)。
self.security_opts = list(security_opts or []) self.security_opts = list(security_opts or [])
self.pids_limit = pids_limit 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 {}) self.extra_env = dict(extra_env or {})
@staticmethod @staticmethod
@@ -387,7 +394,7 @@ class DockerExecutor(Executor):
顺序固定:docker run --rm --name <容器名> --user <用户> 顺序固定:docker run --rm --name <容器名> --user <用户>
--cpus <核数> --memory <内存> [--cap-drop=ALL --cpus <核数> --memory <内存> [--cap-drop=ALL
--security-opt=no-new-privileges] [--security-opt <自定义>] --security-opt=no-new-privileges] [--security-opt <自定义>]
[--pids-limit <n>] [--network <网络>] [--pids-limit <n>] [--runtime <runtime>] [--network <网络>]
-v <worktree>:/work -v <代码目录>:/app:ro -w /work -v <worktree>:/work -v <代码目录>:/app:ro -w /work
[-e <env_key名>] [-e <NAME=VALUE>...] <镜像> <容器内命令...>。 [-e <env_key名>] [-e <NAME=VALUE>...] <镜像> <容器内命令...>。
-e 只传名字,不带 =值(env_key);extra_env 的 -e 带值, -e 只传名字,不带 =值(env_key);extra_env 的 -e 带值,
@@ -415,6 +422,8 @@ class DockerExecutor(Executor):
cmd += ["--security-opt", opt] cmd += ["--security-opt", opt]
if self.pids_limit is not None: if self.pids_limit is not None:
cmd += ["--pids-limit", str(self.pids_limit)] cmd += ["--pids-limit", str(self.pids_limit)]
if self.runtime:
cmd += ["--runtime", self.runtime]
if network: if network:
cmd += ["--network", network] cmd += ["--network", network]
cmd += ["-v", "%s:/work" % self._worktree_for(task), cmd += ["-v", "%s:/work" % self._worktree_for(task),
+70
View File
@@ -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()
+6 -1
View File
@@ -1792,7 +1792,8 @@ def _build_cli_executor(args, host_py):
timeout=args.docker_timeout, timeout=args.docker_timeout,
extra_mounts=args.docker_extra_mounts, extra_mounts=args.docker_extra_mounts,
security_opts=args.docker_security_opt, 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): def main(argv=None):
@@ -1877,6 +1878,10 @@ def main(argv=None):
parser.add_argument("--docker-pids-limit", type=int, default=512, parser.add_argument("--docker-pids-limit", type=int, default=512,
help="docker --pids-limit(默认 512,防 fork 炸弹;" help="docker --pids-limit(默认 512,防 fork 炸弹;"
"codex 工具链正常用量远低于此)") "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, parser.add_argument("--egress-proxy-port", type=int, default=0,
help="egress 过滤代理端口(0 = 不启用;生产配" help="egress 过滤代理端口(0 = 不启用;生产配"
" 8888):启用后 Docker 任务容器的 HTTP(S)" " 8888):启用后 Docker 任务容器的 HTTP(S)"