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:
1 parent
b31e9365de
commit
9f05178f2b
4 files changed
+93
-5
No files matched your search
+6
-2
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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)"
|
||||||
|
|||||||
Reference in new issue
Block a user