跳转到内容

在 Docker 中运行 Potato

用已发布的 Docker 镜像运行 Potato 项目,把标注保存在你自己的磁盘上,并设置容器读取的管理员密钥和环境变量。

本页介绍如何在容器中运行 Potato 标注项目,从第一条 docker run 到把正在进行的研究迁到新版本。本页写给在自己的机器或服务器上运行研究、又不想在主机上安装 Python 的研究人员和实验室管理员。Potato 把镜像发布在 GitHub Container Registry,地址是 ghcr.io/davidjurgens/potato,镜像里只有服务器及其依赖,没有别的内容。容器启动时你的项目文件夹挂载到 /app,所以同一个镜像适用于所有任务。

potato deploy 会在该命令所配置的云主机上运行同一个镜像,并选择与你已安装的 Potato 版本一致的标签。这条路线见在本地或云端运行 Potato,在你自己管理的服务器上使用 gunicorn、反向代理和 TLS 见生产部署。

获取镜像

用 pip 安装 Potato 依赖的 numpy、pandas、scipy 和 scikit-learn,在 1 GB 内存的机器上会耗尽内存,在 2 GB 的机器上要五到十分钟。镜像里已经装好了这些依赖:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0

镜像有两个变体,每个变体都有一个滚动标签、一个按发布版本的标签和一个按提交的标签:

标签内容
latest、2.14.0Potato 的核心依赖、备份客户端(huggingface_hub 和 boto3),以及用于 SSO 登录的 Authlib
latest-all、2.14.0-all上述全部内容,再加上 all 可选依赖:ai_support 背后的 AI SDK、PDF 和 DOCX 导入、parquet 导出以及 MySQL 驱动
sha-<short>、sha-<short>-allmaster 分支的某一次提交

两个变体都为 linux/amd64 和 linux/arm64 构建,arm64 版本可以在 Apple Silicon 上原生运行。两个变体都不包含 vision 可选依赖及其数 GB 的 torch 包。浏览器端分割不需要这个可选依赖,因为 ONNX Runtime Web 已随 Potato 包一起提供。

任何会运行超过一天的研究都应固定版本标签。latest 标签在 master 分支每次变更时都会重新构建,相隔一周的两次 docker pull 可能取到不同的代码。2.14.0 这样的版本标签在发布时推送一次,之后不会重新构建。

自己构建镜像

当你需要两个已发布变体都不包含的可选依赖时,从仓库的克隆构建镜像。POTATO_EXTRAS 构建参数可以取 Potato 的 setup.py 中列出的任一可选依赖,也可以用逗号分隔取多个:

bash
git clone https://github.com/davidjurgens/potato
cd potato
docker build -t potato .
docker build --build-arg POTATO_EXTRAS=all -t potato:all .
docker build --build-arg POTATO_EXTRAS=all,saml -t potato:all-saml .

用于 SAML 登录的 saml 可选依赖不包含在 all 中。只有 POTATO_EXTRAS 中写了 saml 时,Dockerfile 才会安装该可选依赖编译所需的 libxmlsec1 库。本页写 ghcr.io/davidjurgens/potato:2.14.0 的地方,如果用的是本地构建的镜像,请换成它的标签,例如 potato:all。

运行项目

项目文件夹里有 config.yaml 和配置中提到的所有文件。本页的示例使用一个含三个条目的情感标注任务,目录结构如下:

text
myproject/
├── config.yaml
└── data/
    └── items.jsonl

配置中的所有路径都相对于项目文件夹:

yaml
annotation_task_name: "Sentiment pilot"
task_dir: .
data_files:
  - data/items.jsonl
item_properties:
  id_key: id
  text_key: text
output_annotation_dir: annotation_output/
user_config:
  allow_all_users: true
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What is the sentiment of this sentence?"
    labels: [positive, neutral, negative]

data/items.jsonl 的每一行是一个条目:

json
{"id": "1", "text": "The battery lasts all day."}
{"id": "2", "text": "The screen cracked within a week."}
{"id": "3", "text": "It arrived on Tuesday."}

在包含 myproject 的文件夹中启动服务器:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0

任务随后位于 http://localhost:8000。-p 8000:7860 参数把你机器上的 8000 端口映射到容器内服务器监听的 7860 端口。-v 参数把项目文件夹挂载到 /app。主机一侧必须是 $PWD/myproject 这样的路径,因为 Docker 会把 myproject 这样的裸名称当作一个新建的空卷(volume)的名字。docker logs -f potato 持续查看服务器日志,docker stop potato 停止服务器。

含有多份配置的项目用 POTATO_CONFIG 选择其中一份,取值是相对于 /app 的路径:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_CONFIG=configs/pilot.yaml \
  ghcr.io/davidjurgens/potato:2.14.0

运行持续数周的研究

持续数周的研究需要一个在重启和更新之间保持不变的会话签名密钥。没有 POTATO_SECRET_KEY 时,服务器每次启动都会随机生成新密钥,并让所有标注者退出登录。在项目文件夹之外创建一次环境变量文件,之后每条 docker run 都传入同一个文件:

bash
printf 'POTATO_SECRET_KEY=%s\nPOTATO_ADMIN_API_KEY=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > potato.env
chmod 600 potato.env
 
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

该文件第二行的作用见容器中的管理员密钥一节。--restart unless-stopped 会在崩溃或主机重启后让容器自动恢复。

镜像中的其他命令

镜像名称之后的任何内容都会取代服务器。可以用镜像自带的校验器检查配置,也可以在容器里打开一个 shell:

bash
docker run --rm -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 potato validate config.yaml --strict
 
docker run --rm -it -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 sh

Linux 上的文件所有权

镜像中的服务器以 uid 1000 运行,而绑定挂载保留文件夹在主机上的所有权。在 Linux 上,你自己创建的文件夹属于你自己的 uid,由 root 创建的文件夹属于 root。除非文件夹的所有者是 uid 1000,否则服务器无法把标注写入该文件夹。容器会在服务器启动前检查文件夹,并以 ERROR: /app is not writable by uid 1000 停止,而不是在启动途中失败。下面两条命令任选其一都能解决这个问题:

bash
sudo chown -R 1000:1000 myproject                  # hand the folder to the container user
docker run --user "$(id -u):$(id -g)" ...          # or run the container as yourself

使用 --user 时,Potato 写出的标注和管理员密钥文件也属于你自己的账号。macOS 和 Windows 上的 Docker Desktop 会忽略绑定挂载上的所有权,所以在 Mac 上能运行的项目仍可能在 Linux 服务器上失败。POTATO_ALLOW_READONLY_APP=1 会跳过这项检查。Potato 仍会在项目内写入标注、potato.log 和它的 SQLite 数据库,所以只有在这些路径通过单独的挂载可写时才设置该变量。

有些主机不管镜像如何指定,都以 root 启动每个容器。容器以 root 启动时,会把 /app 交给镜像的 potato 用户,并以该用户启动服务器。设置 POTATO_RUN_AS_ROOT=1 则保持以 root 运行。

保存标注

Potato 把研究收集的一切都写入项目文件夹。标注位于 output_annotation_dir 之下,每位标注者一个目录,里面是该标注者的 user_state.json。config.yaml 旁边有两个 SQLite 数据库。project.sqlite 保存备忘、编码本、案例、搜索索引和审阅工作流,datasets.sqlite 保存评估数据集及其版本。这两个数据库都无法从数据文件重建。使用绑定挂载时,所有这些文件都在你的磁盘上,容器被删除后仍然保留。

output_annotation_dir 必须解析到项目文件夹之内。/data/annotations 这样的路径会让服务器在启动时停止,并给出信息 output_annotation_dir: Path '/data/annotations' resolves to '/data/annotations' which is outside the project directory '/app'。请使用 annotation_output/ 这样的相对路径。镜像的 /data 卷同样在项目之外,Potato 不会向其中写入任何内容。

要从运行中的容器复制数据库,请使用 SQLite 的备份命令,不要把它当作普通文件复制。服务器写入数据库期间,project.sqlite 以 WAL 模式运行,普通复制得到的文件可能已损坏,或者缺少最近的工作,而且不会有任何提示。镜像包含 sqlite3 工具,用于生成快照:

bash
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project.sqlite

大多数托管容器的主机会在容器重启时清空容器的文件系统。在这类主机上,请把项目放在挂载到 /app 的持久存储上,或者在配置中加入 backup 块。该块会按计划把标注和数据库快照复制到 S3 存储桶或 Hugging Face 数据集,并在服务器以空磁盘启动时恢复它们。镜像的每个变体都包含该块所需的客户端。

环境变量

容器在启动时读取这些变量:

变量默认值用途
POTATO_CONFIGconfig.yaml配置在容器内的路径,相对于 /app
PORT7860服务器在容器内监听的端口
GUNICORN_WORKERS1工作进程数;任何其他值都会让容器在启动时停止
GUNICORN_THREADS8这一个工作进程中的线程数,也就是同时处理的请求数
GUNICORN_TIMEOUT120工作进程可以保持无响应的秒数,超过后 gunicorn 会重启该进程
POTATO_SECRET_KEY每次启动随机生成为会话 cookie 签名;配置中的 secret_key 优先
POTATO_ADMIN_API_KEY生成的文件管理员 API 密钥;配置中的 admin_api_key 优先
POTATO_GENERATED_TEMPLATES_DIR启动时选定Potato 写入启动时生成的任务模板的位置
POTATO_BUNDLE_URL未设置项目 .tar.gz 的 URL,启动时下载到 /app
POTATO_BUNDLE_SHA256未设置下载的项目必须匹配的校验和
POTATO_BUNDLE_TOKEN未设置作为 Authorization: Bearer 随下载请求发送
POTATO_RUN_AS_ROOT未设置容器以 root 启动时,1 让服务器继续以 root 运行
POTATO_ALLOW_MULTIWORKER未设置1 允许 GUNICORN_WORKERS 超过 1,这在真实研究中会丢失标注
POTATO_ALLOW_READONLY_APP未设置1 跳过启动时对 /app 可写的检查

修改了 PORT 的容器,在 -p 的右侧也需要同一个端口,例如 PORT=9000 对应 -p 8000:9000。

工作进程数和线程数

容器拒绝以多于一个 gunicorn 工作进程启动。Potato 把条目池、分配队列和每位标注者的状态都放在一个进程的内存里。第二个工作进程会拿到这三者各自的一份副本,并把第一个进程已经分配出去的条目再次分配。每次保存都会整份重写 user_state.json,最后保存的那个进程会在没有任何报错的情况下丢弃另一个进程的标注。要处理更多并发请求,请提高 GUNICORN_THREADS,因为线程共享同一份状态。

生成的模板

Potato 在启动时为每个任务的标注页面构建模板。在容器之外,只要安装包的目录可写,模板就写入该目录。镜像以 root 安装 Potato,却以 uid 1000 运行服务器,所以服务器无法写入安装包目录。于是 Potato 把模板写到 /tmp 下一个只有服务器用户可读写的目录,启动日志会给出该目录的名称。Potato 在每次启动时根据配置重新构建模板,丢失这个目录不会有任何损失。设置 POTATO_GENERATED_TEMPLATES_DIR 可以把模板放到别处,例如 tmpfs 挂载。

启动时下载项目

Render、Fly、Railway 和 ECS 这类只运行镜像的主机,没有可以挂载的文件夹。设置了 POTATO_BUNDLE_URL 时,容器会下载项目的 .tar.gz,用 POTATO_BUNDLE_SHA256 校验下载内容,并在服务器启动前把项目解压到 /app。容器把校验和写入 /app/.potato-bundle-sha,在持久磁盘上重启时,如果校验和相符就会跳过下载。在 potato deploy 构建的打包中,新版本的项目绝不会替换磁盘上已有的标注输出或 SQLite 数据库。potato deploy up 会为你设置这些变量。

容器中的管理员密钥

/admin 的管理面板和管理员 API 使用同一个共享密钥,通过 X-API-Key 请求头发送,或者输入到管理员登录页面。Potato 先读取配置顶层的 admin_api_key,再读取 POTATO_ADMIN_API_KEY 环境变量。在容器中,使用环境变量可以让密钥不出现在 config.yaml 里,也不会进入存放项目的任何仓库。运行持续数周的研究一节中的 potato.env 文件设置了这个变量,管理员密钥就是该文件中保存的值:

bash
grep POTATO_ADMIN_API_KEY potato.env

当配置和环境变量都没有设置密钥时,Potato 会在有人第一次打开 /admin 或调用管理员端点时生成一个密钥,并写入任务目录中的 admin_api_key.txt。在容器里,任务目录是 /app,也就是挂载的项目文件夹。该文件只有服务器的用户可读,重启后 Potato 读取的是同一个文件。服务器日志会给出文件名,但不会打印密钥。请通过容器读取密钥:

bash
docker exec potato cat /app/admin_api_key.txt

在 Linux 主机上,如果你把项目文件夹交给了 uid 1000,你自己的账号不使用 sudo 就读不了 admin_api_key.txt。用 --user "$(id -u):$(id -g)" 启动的容器则会以你自己的账号写入该文件。该密钥能打开什么,见管理面板页面。

容器中的调试模式

在任何为研究提供服务的容器里,都请保持 debug: false。配置中设置 debug: true 时,Potato 会改变两个功能的行为:

  • 无需密钥的管理页面。 对于来自回环地址、且不带 X-Forwarded-For、X-Real-IP 或 Forwarded 请求头的请求,管理面板和管理员 API 会跳过密钥。在 Docker 默认的桥接网络上,发往已发布端口的请求是从网桥的地址到达容器的,仍然需要密钥。使用 --network host 时,来自主机本机的请求是回环请求,不需要密钥。位于同一网络命名空间、且不添加转发请求头的反向代理,会让它转发的每个请求都看起来像本地请求。
  • 共享的调试账号。 同时设置了 debug_phase 时,每位访问者都以同一个共享账号 debug_user 登录,并被直接带到该阶段。这个账号不是管理员,但所有访问者共用该账号的答案。

只要 debug 处于开启状态,服务器在启动时就会记录一条警告。用 docker exec 在容器内运行的命令是回环请求。因此在调试模式下,docker exec potato curl -s http://127.0.0.1:7860/admin/health 不需要密钥就会返回管理员健康报告。

健康检查

GET /health 不需要认证,只报告服务器是否已启动。服务器就绪后该路由返回 {"status": "ok"},服务器仍在加载数据时返回状态码 503,负载均衡器或编排器可以把轮询该路由作为就绪检查。镜像自带的 HEALTHCHECK 每 30 秒轮询一次 /health,并为大型任务留出 90 秒的启动期,docker ps 会显示检查结果。GET /admin/health 报告任务名称以及条目管理器和标注者管理器的状态,这个路由需要在 X-API-Key 请求头中提供管理员密钥。

Docker Compose

Potato 仓库不提供 Compose 文件。生产部署给出了一个 Compose 文件,它在 nginx 之后运行这个镜像,项目文件夹挂载在 /app,会话密钥和管理员密钥从环境变量中读取。

更新到新版本

更新 Potato 会替换容器,而项目文件夹连同所有标注和数据库都原样保留。切换之前,先拉取新版本,用新版本的校验器检查配置,并为项目数据库做一次快照。请把 2.14.0 换成你要迁移到的版本:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0
docker run --rm -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 potato validate config.yaml --strict
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project-before-update.sqlite

然后用新标签的容器替换正在运行的容器,使用相同的挂载和相同的环境变量文件:

bash
docker stop potato
docker rm potato
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

由于 potato.env 让 POTATO_SECRET_KEY 保持不变,标注者在切换前后保持登录,管理员密钥也不会改变。

故障排查

ERROR: config file not found: config.yaml。 项目没有挂载到服务器查找的位置。错误信息会列出 /app 的内容,方便你与自己的 -v 参数对照。列表为空通常意味着写了 -v myproject:/app 这样的裸名称。Docker 会把裸名称当作一个新的空卷,而不是你的文件夹。

ERROR: /app is not writable by uid 1000。 服务器无法写入项目文件夹。请把文件夹交给 uid 1000,或者用 --user 运行容器。Linux 上的文件所有权一节给出了两条命令。

output_annotation_dir ... is outside the project directory '/app'。 配置把标注输出指向了挂载文件夹之外。请使用 annotation_output/ 这样的相对路径。

重启后所有人都退出了登录。 没有设置签名密钥,服务器在启动时选了一个新的随机密钥。请在环境变量文件中设置 POTATO_SECRET_KEY,并把该文件传给每一条 docker run。

容器因一条提到 GUNICORN_WORKERS 的错误而退出。 工作进程数大于 1。请设置 GUNICORN_WORKERS=1 或删除该变量,并改为提高 GUNICORN_THREADS。

管理员登录页面拒绝所有密钥。 配置中的 admin_api_key 优先于 POTATO_ADMIN_API_KEY,两者又都优先于 admin_api_key.txt。请先检查配置,再检查 docker exec potato printenv POTATO_ADMIN_API_KEY。

延伸阅读

实现细节见源文档。