DeepTutor 容器化¶
本文档介绍如何通过容器镜像部署 DeepTutor:推荐的 docker run 方式、使用只读根文件系统的加固型 rootless Podman 方式、运行时配置、可选的 PocketBase sidecar 容器,以及支撑默认安全姿态的安全说明。
PyPI / 源码安装请参阅主 README.md。本文件仅涉及运行已发布镜像。
概述¶
已发布的 ghcr.io/hkuds/deeptutor 镜像在 python:3.11-slim 之上,通过 supervisord 在单个容器中同时运行 FastAPI 后端(:8001)和 Next.js 前端(:3782)。容器内的私有运行时目录(/app/data)保存设置、凭据、数据库、记忆、知识库和日志。默认的 Content Workspace 也位于其中,但也可以单独挂载到 /workspace。通过绑定挂载运行时目录和任何独立的 Content Workspace,可让两者在重启后仍然保留。
该镜像支持以下三种部署形态:
docker run— 简单路径。Rootful(使用 root)、可写 rootfs,在/app/data上单个绑定挂载。docker compose(docker-compose.yml)— 相同的镜像,外加 PocketBase sidecar 和 sandbox-runner sidecar。仍然是 rootful,可写 rootfs。podman compose -f compose.yaml— 加固路径。Rootless(userns_mode: keep-id)、只读 rootfs、用 tmpfs 替代可写的系统目录、绑定挂载./data。
让形态 3 得以成立的关键架构变更,是 URL 知识不再存放在前端打包产物中。具体来说:
- 该打包产物在构建时不包含
NEXT_PUBLIC_API_BASE占位符,也不对构建输出执行sed -i。web/lib/api.ts将apiUrl和wsUrl以单行直通方式导出,因此浏览器通过前端(:3782/api/...)获取相对路径。 web/proxy.ts拦截/api/*和/ws/*请求,并在请求时将其改写为DEEPTUTOR_API_BASE_URL。该环境变量由容器入口点每次启动时设置,取值来自data/user/settings/system.json。start-frontend.sh设置PORT/HOSTNAME,并exec运行node /app/web/server.js。不会对打包产物做任何改动。supervisord以 root(PID 1)身份运行,并通过每个程序的user=指令将各程序(后端、前端)降权为名为deeptutor的非 root 用户(UID 1000),因此应用进程保持非 root 运行。在宿主机使用userns_mode: keep-id时,该 UID 会映射到你的宿主 UID;普通docker run下,它只是容器内的普通非特权用户。
完整的按安装方式指南如下。
Docker(默认)¶
最简单的部署方式:一个容器、一个私有数据卷、一个端口映射。
docker run --rm --name deeptutor \
-p 127.0.0.1:3782:3782 \
-v deeptutor-data:/app/data \
ghcr.io/hkuds/deeptutor:latest
打开 http://127.0.0.1:3782。容器在首次启动时创建 /app/data/user/settings/*.json;请在 Web 设置页面配置模型提供商。配置、API 密钥、日志、工作区文件、记忆和知识库都会持久化保存在 deeptutor-data 命名卷中。
Unraid / NAS 绑定挂载(PUID / PGID)¶
Unraid 和许多 NAS 模板会以宿主机用户身份持有 appdata,而该用户通常不是 UID 1000(通常是 nobody:users,即 99:100,或某个命名共享用户)。这些宿主机还会在重启时撤销容器内执行的 chown。镜像仍会将后端/前端降权为非 root 的 deeptutor 用户;入口点会重新映射该用户的 UID/GID,使其与卷匹配:
docker run --rm --name deeptutor \
-e PUID=99 -e PGID=100 \
-p 127.0.0.1:3782:3782 \
-v /mnt/user/appdata/deeptutor:/app/data \
ghcr.io/hkuds/deeptutor:latest
如果运行时用户仍然无法写入 /app/data,入口点会退出并显示一条有关所有者/权限模式的信息,而不会启动后再报 Knowledge base not initialized (llamaindex)。Rootless Podman 会跳过该重新映射(因为没有 CAP_SETUID),并保持当前用户不变。PUID=0 / PGID=0 会被拒绝,因此应用永远不会以 root 身份运行。
选择宿主机上的 Content Workspace¶
应用数据挂载中包含私有设置、凭据、数据库、Memory 和其他运行时状态。请将其与 agent 可读取的 Content Workspace 分开。要暴露宿主机文件夹,可将其挂载到固定的 /workspace 路径,并将部署锁定到该路径:
mkdir -p "$PWD/deeptutor-workspace/outputs"
docker run --rm --name deeptutor \
-p 127.0.0.1:3782:3782 \
-v deeptutor-data:/app/data \
-v "$PWD/deeptutor-workspace:/workspace" \
-e DEEPTUTOR_WORKSPACE_ROOT=/workspace \
-e DEEPTUTOR_WORKSPACE_ALLOWED_ROOTS=/workspace \
ghcr.io/hkuds/deeptutor:latest
对于随附的 Compose 文件:
DEEPTUTOR_WORKSPACE_HOST=/absolute/host/folder \
python scripts/docker_compose.py -f docker-compose.yml up -d
该辅助脚本会在 Compose 启动前创建宿主机文件夹及其嵌套的 outputs/ 目录,避免出现 root 拥有的绑定挂载。若省略该变量,则使用 ./data/user/workspace。容器始终看到 /workspace,因此已保存的设置和面向模型的路径可在不同宿主机间保持可移植。这是一个启动/部署时的选择,并会在 设置 → Workspace 中显示为已锁定。
使用 docker-compose.yml 时,sandbox runner 会以只读方式接收内容树,并获得用于 /workspace/outputs 的可写 overlay;它不会收到 /app/data、用户设置或凭据。因此,生成的程序只能读取所选内容,并只写入当前轮次范围内的输出。单容器和 rootless-Podman 形态会使用该容器中可用的最佳隔离后端,并在 Workspace 设置中显示实际生效的隔离级别。
注意:
- 只需要发布
3782。 浏览器只与前端源(:3782)通信;所有/api/*和/ws/*流量都由 Next.js 中间件(web/proxy.ts)转发到容器内部的 FastAPI 后端,该中间件在请求时读取DEEPTUTOR_API_BASE_URL(默认为http://localhost:8001)。UI 正常工作时,你不需要向宿主机暴露:8001。发布:8001(-p 127.0.0.1:8001:8001)是可选的——只有你想直接调用 API(curl、脚本)或调试时才有用。 - 不同的宿主机端口: 修改每个
-p 宿主机端口:容器端口映射的左侧(例如-p 127.0.0.1:8088:3782)。如果你在data/user/settings/system.json(backend_port、frontend_port)中更改了容器侧端口,请重启容器并更新每个映射的右侧以保持一致。 - 后台运行: 添加
-d,然后使用docker logs -f deeptutor跟踪日志,docker stop deeptutor停止,docker rm deeptutor在重复使用该名称前删除容器。deeptutor-data卷会跨重启保留私有运行时数据和默认 Content Workspace;单独挂载的 Content Workspace 则持久化在其宿主机路径上。
临时本地 Codex OAuth 桥接¶
OpenAI Codex 会将浏览器重定向到固定的回环端口 1455 或 1457。默认的容器网络与宿主机回环隔离,因此仅在登录期间将这两个端口发布到 Web 前端。启动临时桥接前,两个宿主机端口都必须是空闲的。
对于 docker run,先停止正常容器,然后使用同一镜像和数据卷临时重新运行,并额外添加两个仅限回环的映射:
docker run --rm --name deeptutor \
-p 127.0.0.1:3782:3782 \
-p 127.0.0.1:1455:3782 \
-p 127.0.0.1:1457:3782 \
-v deeptutor-data:/app/data \
ghcr.io/hkuds/deeptutor:latest
对于 Compose,在通常使用的基础文件上添加相同的临时覆盖层:
# Source build with sidecars
python scripts/docker_compose.py \
-f docker-compose.yml -f compose.codex-oauth.yaml \
up -d --force-recreate deeptutor
# Pre-built GHCR image
python scripts/docker_compose.py \
-f docker-compose.ghcr.yml -f compose.codex-oauth.yaml \
up -d --force-recreate deeptutor
# Rootless Podman
podman compose -f compose.yaml -f compose.codex-oauth.yaml \
up -d --force-recreate deeptutor
完成 Settings → Models → OpenAI Codex → Sign in with Codex。状态变为 Connected 后,停止临时的 docker run 容器并恢复使用上面的正常命令。对于 Compose,重新运行相同的基础命令但不带 compose.codex-oauth.yaml;保留 --force-recreate deeptutor,以移除临时的端口绑定。
这会释放宿主机端口 1455 和 1457;凭据保留在持久的 /app/data/system 目录树中。请将每个回调映射都绑定到 127.0.0.1,切勿将其暴露在局域网或公共接口上——该覆盖层会将整个前端发布到这两个端口,而不仅仅是 /auth/callback。对于手动 docker run,如果容器侧前端端口不是 3782,请修改右侧的 3782 目标;scripts/docker_compose.py 会处理已配置的自定义端口。对于单容器安装,如果模型生成的代码不得与机密共享容器信任边界,请将 sandbox_allow_subprocess 设为 false。
一次性迁移:docker-compose.ghcr.yml 现在挂载整个 ./data¶
docker-compose.ghcr.yml 过去只绑定挂载三个子树(data/user、data/memory、data/knowledge_bases),因此其他所有内容——data/system(JWT 签名密钥、账户、授权、审计日志、每个所有者的 Codex 令牌)、data/users(每个用户的工作区)、data/partners、data/cli-apps——都位于容器的可写层中,并在每次重新创建时被丢弃。现在它挂载整棵目录树,与 docker-compose.yml 和 compose.yaml 保持一致。
在升级后首次运行 up -d 之前,请将该状态从正在运行的容器中复制出来;否则空的宿主目录会遮蔽它,DeepTutor 将重新生成认证密钥(使所有人退出登录),并以没有任何非管理员账户的状态启动:
for tree in system users partners cli-apps; do
docker cp "deeptutor:/app/data/$tree" "./data/$tree" 2>/dev/null || true
done
使用命名卷(-v deeptutor-data:/app/data)或源码构建 Compose 文件的部署从未受到影响——它们本来就会持久化整棵目录树。
远程 / 反向代理部署¶
对于常见的单容器场景(本镜像),您完全不需要配置 API 基地址。浏览器会向提供 UI 的任何源(https://deeptutor.example.com)发出相对的 /api/* 和 /ws/* 请求,容器内的 Next.js 中间件会将这些请求转发到 localhost:8001 上的后端。只需将您的反向代理 / TLS 终止器指向已发布的 :3782 即可。
只有在拆分部署(后端运行在独立容器中)时才需要设置 API 基地址。在宿主机上编辑 data/user/settings/system.json(位于 deeptutor-data 卷内——可用 docker volume inspect deeptutor-data 查看其挂载点),并设置前端容器用于访问后端容器的网络内地址:
入口点会在每次启动时读取此配置,并为 proxy.ts 导出 DEEPTUTOR_API_BASE_URL(优先级:next_public_api_base,然后是 next_public_api_base_external,最后是 http://localhost:8001)。请注意,由于代理是服务端的,DEEPTUTOR_API_BASE_URL 是前端服务器用来访问后端的地址——而不是浏览器会看到的 URL。public_api_base 作为兼容性别名被接受,并在保存时规范化为 next_public_api_base_external。
CORS 使用前端的源,而不是 API URL。在禁用认证的情况下,DeepTutor 默认允许常规的 HTTP/HTTPS 浏览器源。在启用认证的情况下,请添加精确的前端源:
宿主机 LLM 提供商(Ollama / LM Studio / llama.cpp / vLLM / Lemonade)¶
在 Docker 内部,localhost 指的是容器本身,而不是您的宿主机。要访问运行在宿主机上的模型服务,请使用宿主机网关(推荐):
docker run --rm --name deeptutor \
-p 127.0.0.1:3782:3782 -p 127.0.0.1:8001:8001 \
--add-host=host.docker.internal:host-gateway \
-v deeptutor-data:/app/data \
ghcr.io/hkuds/deeptutor:latest
然后在 Settings → Models 中,将提供商的 Base URL 指向 host.docker.internal:
- Ollama LLM:
http://host.docker.internal:11434/v1 - Ollama 嵌入:
http://host.docker.internal:11434/api/embed - LM Studio:
http://host.docker.internal:1234/v1 - llama.cpp:
http://host.docker.internal:8080/v1 - Lemonade:
http://host.docker.internal:13305/api/v1
Docker Desktop(macOS/Windows)通常无需 --add-host 即可解析 host.docker.internal。在 Linux 上,使用该标志是可移植的方式。
Linux 备选方案——宿主机网络: 添加 --network=host 并去掉 -p 标志。容器直接共享宿主机网络,因此打开 http://127.0.0.1:3782(或 system.json 中的 frontend_port),即可使用正常的 localhost URL 访问宿主机服务。
在宿主机网络模式下,进程直接绑定到宿主机接口上(没有 -p 127.0.0.1: 前缀来限定它们)。要让它们不暴露到局域网中,请设置 BACKEND_HOST=127.0.0.1 和 FRONTEND_HOST=127.0.0.1 —— 它们会覆盖 uvicorn 的 --host 和 Next.js 的 HOSTNAME(两者默认都是 0.0.0.0)。仅在 --network=host 下使用这些设置:在桥接模式下绑定到回环地址会破坏已发布的 -p 端口转发。
Podman / rootless / 只读 rootfs¶
对于希望采用最强默认安全姿态(rootless,且根文件系统只读)的用户,compose.yaml 是受支持的起点。它会拉取相同的 ghcr.io/hkuds/deeptutor:latest 镜像,并依赖入口点的 chown + supervisord 的逐程序权限降级、URL 转发 proxy.ts,以及宿主机侧的 bind mount 来让这一切正常运转。
cp .env.example .env # then edit if needed
podman compose -f compose.yaml up -d
podman compose -f compose.yaml ps
podman compose -f compose.yaml logs -f deeptutor
验证 rootless 已激活(podman info | grep -i rootless 应返回 true)。
compose.yaml 做了什么,以及为什么:
- 每个服务都设置
read_only: true。 容器的 rootfs 是只读的。唯一可写的区域是每个服务下所列出的tmpfs:挂载,以及 bind-mount 的./data和 Content Workspace 目录。 userns_mode: keep-id。 使用运行时的 keep-id 用户命名空间。请在目标宿主机上检查实际的映射;不要假设容器 UID 0 或 UID 1000 对每个宿主机账户的映射都相同。:U挂载选项会根据容器映射调整属主,并可能改变宿主机的目录属主。请使用专用的数据目录并验证写入权限。- 为运行时需要写入的系统目录提供
tmpfs:挂载。/tmp(Python/Node 临时文件)、/run和/var/run(pid 文件)、/var/log、/root、/home。大小故意留得比较充裕;可按需裁剪。 - 不使用命名卷。 Podman 会使用 userns 映射的 root(UID 100000)自动创建命名卷,因此 755 权限 + 属主错误 = 第一次写入 JSON 时就出现
PermissionError。在你自己拥有的宿主机目录上进行 bind mount 可以干净利落地工作。 - 仅回环地址的端口绑定。 每个
ports:条目都带有127.0.0.1:前缀。去掉前缀即可在所有接口上暴露。 - 没有 sandbox-runner 伴生容器。
docker-compose.yml的形态包含一个加固的伴生容器,用于在最小权限容器中运行不受信任的模型生成代码。podman 形态则没有——主应用会回退到bwrap(Linux,如果镜像中已安装)或受system.json中sandbox_allow_subprocess控制的受限子进程后端。
在 compose.yaml 之外运行¶
compose.yaml 是一个起点,而不是唯一的形态。如果你想直接使用 podman run,同样的不变式也适用:
mkdir -p data/user/settings
# Create defaults only when no settings exist; preserve existing configuration.
if [ ! -f data/user/settings/system.json ]; then
printf '{}\n' > data/user/settings/system.json
fi
podman run --rm -d --name deeptutor \
-p 127.0.0.1:8001:8001 \
-p 127.0.0.1:3782:3782 \
-v $(pwd)/data:/app/data:U \
--read-only \
--tmpfs /tmp:size=512m,mode=1777 \
--tmpfs /run:size=32m,mode=0755 \
--tmpfs /var/run:size=8m,mode=0755 \
--tmpfs /var/log:size=64m,mode=0755 \
--tmpfs /root:size=16m,mode=0700 \
--tmpfs /home:size=16m,mode=0755 \
--userns=keep-id \
ghcr.io/hkuds/deeptutor:latest
容器启动后,后端和前端始终以非 root 的 deeptutor 用户(UID 1000)运行——podman exec deeptutor ps -o user,pid,comm 会显示 uvicorn/node 子进程为 deeptutor。supervisord 本身(PID 1)以运行时启动它时的任何 UID 运行:在 rootful Docker/Podman 下是 root,在 rootless podman + userns_mode: keep-id 下则是宿主机用户。
Supervisord 的 pidfile¶
[supervisord] 部分没有 user= 指令,因此 supervisord 以 PID 1 的 UID 运行,并且从不尝试降低自身权限;只有它的子程序通过每个程序的 user= 指令降级为 deeptutor。在这里固定 user=root(早期设计)会破坏 rootless keep-id,因为此时 PID 1 是非 root 的宿主机用户且缺少 CAP_SETUID:supervisord 拒绝降低权限并在启动时以 Can't drop privilege as nonroot user 退出(参见 supervisord 的 options.py)。
pidfile 被写入 /tmp/supervisord.pid。在上述所有运行配置中,/tmp 都是 mode=1777(世界可写),因此无论 PID 1 是 root 还是宿主机 UID,也无论 /var/run 的属主是谁,pidfile 都是可写的。早期版本曾将 pidfile 指向 root 拥有的 /var/run/supervisord.pid;在 rootless keep-id 下,非 root 的 PID 1 无法写入它,并在每次启动时记录一条无伤大雅的 CRIT could not write pidfile。将它放在 /tmp 中完全消除了对 /var/run 属主和模式的依赖。
运行时配置¶
你几乎要调优的所有内容都位于数据树中的 data/user/settings/ 下。容器入口点会在每次启动时取消设置一系列相关的环境变量(BACKEND_PORT、FRONTEND_PORT、NEXT_PUBLIC_API_BASE、NEXT_PUBLIC_API_BASE_EXTERNAL、AUTH_ENABLED、POCKETBASE_URL 等),并重新从 JSON 文件中导出值。所以:编辑 JSON 文件,重启,不要尝试用 compose 环境变量来驱动这些设置。
| 文件 | 用途 |
|---|---|
system.json |
后端/前端端口、公共 API 基础地址、CORS、SSL 验证、附件目录 |
auth.json |
可选的认证开关、用户名、密码哈希、token/cookie 设置 |
integrations.json |
可选的 PocketBase 和伴生容器集成设置 |
model_catalog.json |
LLM、嵌入和搜索提供方配置;API 密钥;激活的模型 |
interface.json |
UI 语言 / 主题 / 侧边栏偏好 |
main.yaml |
运行时行为默认值和路径注入 |
agents.yaml |
能力/工具 temperature 和 token 设置 |
与全新安装最相关的两个设置:
system.json→next_public_api_base(网络内)和next_public_api_base_external(云/外部覆盖)。入口点读取这些值并导出DEEPTUTOR_API_BASE_URL,web/proxy.ts会使用该变量。public_api_base作为兼容性别名被接受,并在保存时归一化到next_public_api_base_external。system.json→backend_port/frontend_port。这是 supervisor 在容器内部绑定的容器端口。如果更改这些端口,请同步更新每个-p host:container映射的右侧(或compose.yaml读取的HOST_PORT_*环境变量)以保持一致。
项目根目录的 .env 文件被有意忽略,不作为应用配置。Web 设置页面是编辑 JSON/YAML 文件的推荐方式;每个部分的深层链接位于页面侧边栏。
PocketBase¶
PocketBase 是一个可选的认证 + 存储 sidecar。通过在 data/user/settings/integrations.json 中将 integrations.pocketbase_url 设置为 http://pocketbase:8090,并将 pocketbase 服务与主 deeptutor 服务一起启动来激活它。运行后,主应用会将用户账户和会话存储在 PocketBase 中,而不是回退到 SQLite 单用户布局。
compose.yaml 中的 pocketbase 服务(以及 docker-compose.yml 中修正后的挂载)将 ./data 的三个子目录 —— /pb_data、/pb_public、/pb_hooks —— 以 bind-mount 方式挂载,这与上游 ghcr.io/muchobien/pocketbase:latest 镜像的入口点(使用绝对路径)相匹配。之前的 docker-compose.yml 示例挂载的是 /pb/pb_data,首次启动时会因 mkdir /pb_data: read-only file system 崩溃;本 PR 修复了这个问题。
PocketBase 保持为单用户集成——在多用户部署中,请将 integrations.pocketbase_url 留空,除非你已经接入了外部用户存储。
故障排查¶
容器启动时出现 CRIT could not write pidfile /var/run/supervisord.pid。 仅在 pidfile 移动到 /tmp/supervisord.pid(mode=1777,始终可写)之前构建的镜像上出现;当前镜像不会输出此信息。受监督的子进程无论如何都会启动——该日志行一直只是表面问题。修复方法:拉取当前镜像(或在旧镜像上,将 /var/run tmpfs 设置为 mode=1777)。
页面加载正常,但设置页显示 “Backend unreachable”。 UI 通过容器内代理访问后端,而非主机端口,因此这几乎总是后端启动失败(查看 docker logs deeptutor 中的 [program:backend] 日志行)或拆分部署中 DEEPTUTOR_API_BASE_URL 配置错误所致——不是缺少 :8001 主机映射(UI 并不需要它)。
在 podman 主机上出现 Cannot connect to the Docker daemon。 运行 systemctl --user start podman.socket(rootless)或为 docker-compose CLI 设置 DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock,以使用 podman socket。
在命名卷下首次写入 JSON 时出现 Permission denied。 这是 userns 映射的 root 问题;请切换到你所拥有的主机目录上的 bind mount,或在卷挂载上使用 :U。
在 Unraid 上出现 Data directory is not writable / Knowledge base not initialized (llamaindex)。 应用以非 root 的 deeptutor 用户运行。将 PUID/PGID 设置为主机 bind mount 的所有者(参见 Unraid / NAS 绑定挂载)。不要通过以 root 运行容器来规避此问题。
在新镜像上出现 sed -i 错误。 本不应出现——运行时不再修改 bundle。URL 会在请求时转发。如果你看到此类错误,很可能使用的是旧镜像;请重新拉取 ghcr.io/hkuds/deeptutor:latest。
设置页不接受 API 基础 URL。 在主机上打开 data/user/settings/system.json,直接设置 next_public_api_base_external。页面 UI 绑定的是 public_api_base(旧版别名),旧版字段会在保存时重新归一化。
安全说明¶
- 镜像在启动
supervisord前会将权限降级为非 root 的deeptutor用户(默认 UID 1000,或在 rootful Docker 下使用PUID/PGID)。以 root 身份运行的只有入口点、对/app/data的 chown 尝试以及环境变量导出。应用进程本身不会保持 root 权限。 read_only: true加上为预期可写的系统目录配置的tmpfs:,意味着容器的根文件系统在运行时不可变。任何试图写入所列 tmpfs 路径之外或 bind-mounted./data目录树之外的进程都将失败。- 主机上的
userns_mode: keep-id意味着容器逃逸后将以你主机用户的权限运行,而不是 root。 - sandbox-runner sidecar(位于
docker-compose.yml中,不在compose.yaml中)是处理不可信模型生成代码的最强姿态:沙箱逃逸后会落入一个精简、无特权的容器,不包含应用机密,而不是进入主应用。podman 形态会以此换取 rootless-podman 形态;主应用会回退到bwrap或由sandbox_allow_subprocess控制的受限子进程后端。 - 认证(
data/user/settings/auth.json→auth_enabled = true)通过dt_tokencookie 对/api/*和/ws/*进行门控。web/proxy.ts读取DEEPTUTOR_AUTH_ENABLED(每次启动时由入口点导出)来决定是否需要该 cookie。 - CORS 使用前端的 origins,而非 API URL。启用认证后,将
system.json中的cors_origins设置为部署所服务的精确前端 origins。 - 可选的请求筛查(速率限制、IP 允许/拒绝列表、攻击模式扫描)可通过
system.json中的security_guard以及guardextra(DEEPTUTOR_EXTRAS: "guard")获得。默认关闭;参见 SECURITY_GUARD.md。
实现与验证¶
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 dee/DeepTutor