五个 compose 文件各自干什么,别拿错那一份
克隆完 DeepTutor,ls 一眼仓库根目录,会看到五个名字高度相似的 compose 文件:compose.yaml、docker-compose.yml、docker-compose.dev.yml、docker-compose.ghcr.yml、compose.codex-oauth.yaml。文件名里没有任何一处告诉你它们的关系——哪份是主的、哪份是叠加层、哪份根本不能单独 up。拿错一份的代价不只是启动报错:有两份的服务组成本来就不同(其中一份明确不含 sandbox-runner sidecar),还有一份的注释里记着早期挂载范围过窄、导致 data/system 留在容器可写层的历史缺陷。
以下行数、服务名、挂载路径、阈值全部对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。行号会随版本漂,文件名和字段名是稳的。
一、先把五份摆在一张表里
| 文件 | 行数 | 服务数 | 定位 |
|---|---|---|---|
compose.yaml | 206 | 2(pocketbase、deeptutor) | 面向 rootless Podman + 只读 rootfs |
docker-compose.yml | 182 | 3(pocketbase、deeptutor、sandbox-runner) | 本地构建的完整形态 |
docker-compose.dev.yml | 51 | — | override 文件,改 build target 与挂载 |
docker-compose.ghcr.yml | 71 | 1(deeptutor) | 直接拉 GHCR 镜像跑 |
compose.codex-oauth.yaml | 9 | 1(仅 ports 覆盖) | 登录期间临时叠加的 overlay |
这张表最该先看的是服务数那一列。三个数字 2、3、1 意味着同一个项目在三种部署形态下的进程组成是不同的,而不是同一套东西换个写法。CONTAINERIZATION.md(467 行,17 个一到三级标题)在第 22-31 行明确写了三种形态:docker run(rootful、可写 rootfs、单 bind mount)、docker compose(加 PocketBase 与 sandbox-runner 两个 sidecar)、podman compose -f compose.yaml(rootless、只读 rootfs、tmpfs)。表里的服务数就是这三行的落地。
二、反直觉的那一处:这两份不该用 docker compose up 直接起
大多数人的肌肉记忆是 docker compose up -d。在这个仓库里,docker-compose.yml 与 docker-compose.ghcr.yml 都在文件头部要求你改用 scripts/docker_compose.py 启动(docker-compose.yml:14、docker-compose.ghcr.yml:15)。
理由写在脚本自己的 docstring 里:scripts/docker_compose.py(112 行)第 2-7 行写的是 “Run Docker Compose with port mappings rendered from JSON settings.”——因为 compose 不能直接读 system.json,所以这个脚本先渲染一个临时 env 文件,再去调 docker compose --env-file。
这一处之所以反直觉,是因为它把「端口从哪来」这件事的真源,放在了 compose 文件之外。顺着这条线往下,还有两个容易踩的推论:
第一,.env 不是这个项目的配置入口。 .env.example 只有 25 行,里面只有四个变量:HOST_PORT_BACKEND=8001、HOST_PORT_FRONTEND=3782、HOST_PORT_POCKETBASE=8090、TZ=UTC,并且文件里强调 API base URL 不是 compose 环境变量(.env.example:10-25)。CONTAINERIZATION.md 第 377 行写得更直白:项目根目录的 .env 文件”被有意忽略、不作为应用配置”。也就是说,你往 .env 里加一行自定义配置,指望它进到应用里,这条路在设计上就是不通的。
第二,容器启动时会把一批环境变量先清掉再重设。 Dockerfile:326-354 里的 entrypoint 会先 unset 一长串运行时环境变量(BACKEND_PORT、FRONTEND_PORT、AUTH_ENABLED、POCKETBASE_* 等共 21 个 key),再从 JSON 重新导出,并 export DEEPTUTOR_IGNORE_PROCESS_ENV_OVERRIDES=1。
把这三件事连起来读:端口的真源是 system.json,脚本负责把它渲染成 env 文件,entrypoint 再负责把进程环境里可能残留的旧值洗掉。你直接 docker compose up,跳过的是第一步渲染。至于跳过之后具体会呈现成什么样,我们没有运行过,不做推断——能确定的是仓库自己在两个文件头都要求走脚本。
配套要知道的还有一条:CONTAINERIZATION.md 第 192-198 行记录了 API base 的优先级是 next_public_api_base → next_public_api_base_external → http://localhost:8001,public_api_base 是兼容别名,保存时会归一化。前端 bundle 不再内嵌 URL,由 web/proxy.ts 在请求时把 /api/*、/ws/* 重写到 DEEPTUTOR_API_BASE_URL,而这个变量由 entrypoint 每次启动从 system.json 读出(CONTAINERIZATION.md:36-43)。需要说明的是,web/ 下的前端源码我们这次没有读,上面这段是 CONTAINERIZATION.md 的文字口径,没有与实现核对过。
三、docker-compose.yml:多出来的那个服务是执行外部程序的
三个服务里,pocketbase 映射 :8090,deeptutor 的 build target 是 production、映射 8001 与 3782(docker-compose.yml:27、:54-63)。真正需要单独讲的是第三个:sandbox-runner,它 build 自 Dockerfile.runner(docker-compose.yml:112-115)。
这个服务没有 ports:,只在内部网络上以 http://sandbox-runner:8900 可达,主服务通过环境变量 DEEPTUTOR_SANDBOX_RUNNER_URL=http://sandbox-runner:8900 路由过去(docker-compose.yml:83、:110-111、:146)。
它的加固项一次性列全(docker-compose.yml:149-161):
no-new-privileges:truecap_drop: ALLread_only: true- tmpfs 挂
/tmp与/home/runner pids_limit: 256mem_limit: 1g
这些数值是 compose 文件里的默认配置,不是”你跑起来一定安全”的保证。这里必须如实说清楚这个容器在干什么:Dockerfile.runner(110 行)是单阶段镜像,不含任何 app 代码,只 COPY 了一个文件 deeptutor/services/sandbox/runner/server.py 到 /app/server.py(Dockerfile.runner:19、:98),注释写明它 “must not depend on the DeepTutor package or any heavy framework — keeping the attack surface and image size minimal”(Dockerfile.runner:14-16)。它建了 uid 1000 的 runner 用户,ENV RUNNER_PORT=8900、EXPOSE 8900、USER runner、CMD ["python", "/app/server.py"](Dockerfile.runner:90-110)。
而它挂了三样东西(docker-compose.yml:119-144):./data/user/workspace、./data/users、./data/cli-apps:ro。第三个挂载点配合 Dockerfile.runner:37-42 的注释一起读会更清楚——那里解释了为什么装 nodejs 而不装 npm:CLI apps 由主容器安装,runner 只读挂载并执行。换句话说,这条链路上确实存在「在容器里执行你本机安装的第三方 CLI 程序」这件事,不是只跑项目自己的代码。你要不要开这个 sidecar,得按自己的环境判断,不能因为上面那串加固项就当它不执行外部程序。
同一段注释还坦承了一处当前的边界,原文写的是每个命令在这个容器里共享同一份文件系统视图,“per-command isolation is a roadmap item”;并写明非 admin 账号之间通过 exec 可以互读对方的 settings、chat_history.db 与 knowledge_bases,“That cross-user visibility is accepted for the invite-only trust posture and enforced no further.”(docker-compose.yml:124、:119-144)。这是仓库自己写下的现状,照实转述,不做评价。
对照着看,compose.yaml 那条路明确不含 sandbox-runner sidecar,会退化到 bwrap,或者退化到由 sandbox_allow_subprocess 控制的受限子进程(compose.yaml:38-42)。所以「用 podman 那份还是用 docker 那份」不是写法偏好问题,它直接决定了沙箱形态。CONTAINERIZATION.md 的 Security notes 一共 5 条,其中一条正是把 sandbox-runner sidecar 列为最强姿态(CONTAINERIZATION.md:443-467)。
四、compose.yaml:为 rootless Podman 单独写的那份
这份文件第 1-11 行就把适用面写死了:面向 rootless Podman 与只读 rootfs,要求 podman 4.1+,声明兼容 podman-compose 1.5+。两个服务的端口是 127.0.0.1:${HOST_PORT_BACKEND:-8001}:8001、127.0.0.1:${HOST_PORT_FRONTEND:-3782}:3782,PocketBase 是 127.0.0.1:${HOST_PORT_POCKETBASE:-8090}:8090(compose.yaml:85-86、:144-146)——注意三个映射都绑在 127.0.0.1 上。
这份里还有一处值得单独记:它刻意不使用 named volume,注释解释的原因是 podman 会以 UID 100000 创建,导致首次写 JSON 时报 PermissionError(compose.yaml:202-206)。如果你按自己的习惯把 bind mount 改成 named volume,改的就是这一处被特意绕开的坑。
与之相关的还有一条运行时权限设计:Dockerfile:211-223 里 [supervisord] 段刻意不写 user=,pidfile 放 /tmp/supervisord.pid,注释解释是 rootless podman 加 keep-id 的场景下没有 CAP_SETUID,写了 user= 会启动失败;两个 program 则各自带 user=deeptutor(Dockerfile:234-258)。
这里有一处仓库内部对不上的地方,按纪律只陈述差异、标明位置:CONTAINERIZATION.md 的 Overview 写 supervisord 以 root(PID 1)运行、再按 program 把子进程降到 deeptutor 用户(CONTAINERIZATION.md:46-50),这与 Dockerfile 注释一致(Dockerfile:211-217、:231-233);但同一个文件的 Security notes 写的是镜像”在启动 supervisord 之前”就降权到非 root 的 deeptutor 用户(UID 1000)(CONTAINERIZATION.md:445-446)。两处不一致,以我们实读的仓库状态为准。说完就停。
五、两份不能单独用的:dev override 与 codex overlay
docker-compose.dev.yml(51 行)是 override 文件,不是完整栈。它做三件事:把 build target 改成 development,只读挂载 deeptutor、deeptutor_cli、scripts 与 web 的 8 个子目录,可写挂载 data/user、data/memory、data/knowledge_bases(docker-compose.dev.yml:17-40)。对应的 development stage 在 Dockerfile:444 由 FROM production 派生,额外装回完整 node_modules、装 vim/git 与 pre-commit/black/ruff,并把 backend 换成 uvicorn --reload、frontend 换成 node scripts/dev.mjs(Dockerfile:444-498)。
这份里有一处与另外三处口径相反的挂载,值得在动手前先核一遍:dev override 挂的是 ./data/pocketbase:/pb/pb_data(docker-compose.dev.yml:15),而 docker-compose.yml、compose.yaml 与 CONTAINERIZATION.md 三处都指出上游镜像用的是没有 /pb/ 前缀的绝对路径,旧写法会以 mkdir /pb_data: read-only file system 崩溃(docker-compose.yml:34-38、compose.yaml:90-93、CONTAINERIZATION.md:392-398)。四处位置写法不同:dev override 带 /pb/ 前缀,另外三处写的是无前缀的绝对路径。以我们实读的仓库状态为准,说完就停。
compose.codex-oauth.yaml 只有 9 行,内容是一个 deeptutor 服务的 ports 覆盖,把 127.0.0.1:1455 与 127.0.0.1:1457 映射到前端端口。它的注释把用法限定得非常死:只在登录期间叠加,登录完要用基础 compose 文件重建服务,把 1455 与 1457 两个端口还给其它 Codex 客户端(compose.codex-oauth.yaml:1-9)。这两个端口不是随便挑的——CONTAINERIZATION.md 第 93-128 行说明 OpenAI Codex 固定回跳 loopback 端口 1455 或 1457,所以容器化场景要临时把它们发布出去。
用这条路之前请注意仓库自己的标注:README 关于 OpenAI Codex OAuth 的整节标题写的是 “OpenAI Codex OAuth (experimental).”(README.md:636),节末又写了一次 “This compatibility path is experimental: the upstream interface may change.”(README.md:656)。
六、docker-compose.ghcr.yml:单服务,以及那条写在注释里的历史缺陷
这份 71 行的文件只有一个 deeptutor 服务,直接拉 ghcr.io/hkuds/deeptutor:latest,pull_policy: always,挂整棵 ./data(docker-compose.ghcr.yml:39-57)。README 的 Option 3 给的镜像标签是 :latest(stable)与 :pre(pre-release,README 原文标注为 “when available”)(README.md:295-296);README 还写了单容器只需要发布 3782,8001 的发布是可选的(README.md:307)。
真正需要读的是它注释里记的那条历史缺陷(docker-compose.ghcr.yml:50-56):过去这份文件只挂 3 个子目录,导致 data/system(auth secret、accounts、grants、Codex tokens)等留在容器的可写层,每次 recreate 就被丢弃;升级需要做一次性拷贝,注释指向 CONTAINERIZATION.md 的 “One-time migration” 小节。那份一次性迁移脚本给的是 docker cp 循环,覆盖 system、users、partners、cli-apps 四棵树(CONTAINERIZATION.md:161-165)。
这一条对老用户尤其要紧:如果你的 compose 文件是早先复制走的、只挂了那 3 个子目录,那么”挂整棵 ./data”这个改动就不是可有可无的写法调整。这里涉及的是认证密钥与令牌一类的本机敏感数据,怎么迁、要不要迁,请结合自身环境评估。
七、你该拿哪一份:从处境倒推
不列参数矩阵,直接按你的处境走:
- 只想跑起来、不改代码、能拉 GHCR 镜像 →
docker-compose.ghcr.yml,用scripts/docker_compose.py起。代价是这条路只有一个服务,没有 PocketBase 与 sandbox-runner 两个 sidecar。 - 要本地构建、要完整形态(含沙箱 sidecar) →
docker-compose.yml。请先把上文第三节那串加固项与「执行外部 CLI 程序」这件事读完再决定。 - 环境是 rootless Podman / 要求只读 rootfs →
compose.yaml。请先接受它明确不含 sandbox-runner、会退化到 bwrap 或受限子进程这一点。 - 要改代码、要热重载 →
docker-compose.yml叠加docker-compose.dev.yml,后者不能单独用。 - 正在做 Codex OAuth 登录 → 临时叠加
compose.codex-oauth.yaml,登完立刻用基础文件重建。这条路整节被标为 experimental。
有一件事我们没有依据、因此不比:这五份文件在各自目标运行时上的实际行为差异。我们既没有安装也没有运行过该项目,上面所有结论都停在”文件里写了什么”这一层。
八、可复现的核查动作
wc -l compose.yaml docker-compose.yml docker-compose.dev.yml docker-compose.ghcr.yml compose.codex-oauth.yaml,看行数是否还是 206 / 182 / 51 / 71 / 9;行数变了说明结构动过,下面的行号都要重新定位。- 打开
docker-compose.yml:14与docker-compose.ghcr.yml:15,确认头部那句”要用scripts/docker_compose.py启动”还在;再打开scripts/docker_compose.py:2-7读 docstring 里的理由。 - 在
docker-compose.yml里搜sandbox-runner,把:119-144的挂载清单与:149-161的加固项抄下来,再回compose.yaml:38-42确认那份没有这个服务。 - 把
docker-compose.dev.yml:15的 PocketBase 挂载路径,与docker-compose.yml:34-38、compose.yaml:90-93的写法并排比一遍,看/pb/前缀这处差异是否还在。 cat .env.example数变量个数(应为四个),再对照CONTAINERIZATION.md:377那句”项目根.env被有意忽略”。这两处一起看,才能确定你的配置该往哪写。- 想确认容器起来之后端口从哪读,看
Dockerfile:414-427的 healthcheck——它从/app/data/user/settings/system.json读backend_port,默认 8001。
需要说明的边界:本文只读了五个 compose 文件、两个 Dockerfile 的关键段落与 CONTAINERIZATION.md 的部分区段,web/ 前端源码与 deeptutor/services/sandbox/ 的实现都没有读,凡是涉及”跑起来之后会怎样”的部分一律不下结论。文中出现的端口、上限与加固项都是仓库里的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。