Skip to content

生产部署

用 gunicorn、nginx 或 Apache 把 Potato 部署到生产环境,包括 TLS 终止、Docker、会话持久化,以及共享服务器所需的访问控制。

把 Potato 部署到真实的标注任务中,支持多用户、数据持久化和恰当的安全设置。

服务器配置

基础生产配置

yaml
server:
  host: 0.0.0.0
  port: 8000
  debug: false

server 块只接受 host、port 和 debug 三个键,没有别的。工作进程数和超时属于运行 Potato 的那个 WSGI 服务器,而不属于配置文件。生产环境请关掉 debug,因为调试模式会让来自服务器本机的请求不用密钥就能打开管理页面,而在已部署的主机上,这包括它的反向代理。

使用 HTTPS

Potato 的配置里没有任何 TLS 设置,所以证书不在这里配。TLS 要在 Potato 前面的反向代理上终止,也就是下面 nginx 和 Apache 两节所做的事;或者像"启用 SSL"里那样把证书直接交给 gunicorn。

用 Gunicorn 运行

用 gunicorn 运行 Potato 时只用一个 worker,需要更高并发时提高线程数:

bash
gunicorn "potato.flask_server:create_app('config.yaml')" \
  --workers 1 \
  --threads 8 \
  --bind 0.0.0.0:8000 \
  --timeout 120

单 worker 是正确性要求,不是调优取舍。Potato 把项目池、分配队列和每名标注者的状态都放在进程内存里。第二个 worker 会拿到这三者各自的一份副本,于是它会把第一个 worker 已经分配出去的实例再发一遍;而由于 user_state.json 每次保存都是整份重写,最后保存的那个 worker 会把另一个的标注丢掉。没有任何地方会报告这个损失,标注就这么没了。线程共享同一份状态,所以 --threads 可以安全地提高并发,--workers 不行。

启用 SSL

bash
gunicorn "potato.flask_server:create_app('config.yaml')" \
  --workers 1 \
  --threads 8 \
  --bind 0.0.0.0:443 \
  --certfile /path/to/cert.pem \
  --keyfile /path/to/key.pem

反向代理配置

Nginx 配置

nginx
upstream potato {
    server 127.0.0.1:8000;
}
 
server {
    listen 80;
    server_name annotation.example.com;
    return 301 https://$server_name$request_uri;
}
 
server {
    listen 443 ssl;
    server_name annotation.example.com;
 
    ssl_certificate /etc/letsencrypt/live/annotation.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/annotation.example.com/privkey.pem;
 
    location / {
        proxy_pass http://potato;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # Long annotation sessions
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
    }
}

Apache 配置

apache
<VirtualHost *:443>
    ServerName annotation.example.com
 
    SSLEngine on
    SSLCertificateFile /path/to/cert.pem
    SSLCertificateKeyFile /path/to/key.pem
 
    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:8000/
    ProxyPassReverse / http://127.0.0.1:8000/
 
    <Proxy *>
        Order allow,deny
        Allow from all
    </Proxy>
</VirtualHost>

挂载到某个 URL 路径下

把 Potato 放在 https://host/app1/ 而不是域名根下,需要多做一步。标注页面请求的 /static/... 和 /updateinstance 都是相对公开根路径的,所以在路径前缀下这些静态资源会 404,标注也保存不上,而页面外壳照样渲染出来。

要告诉 Potato 它的公开前缀。如果代理在你手里、并且它能发送转发头:

bash
export POTATO_PROXY_FIX=1
potato start config.yaml -p 8000
nginx
location /app1/ {
    proxy_pass         http://127.0.0.1:8000/;   # trailing slash strips /app1/
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   X-Forwarded-Prefix /app1;
}

只有当应用只能经由那个代理访问时才开 POTATO_PROXY_FIX。它会信任转发头,所以能直接连到内部端口的客户端可以伪造前缀,进而污染所有生成出来的 URL。

如果你加不了请求头但知道挂载路径,就直接设置它。代理仍然必须在转发之前把前缀去掉:

bash
export POTATO_URL_PREFIX=/app1
potato start config.yaml -p 8000

测试时请用一个没有会话的浏览器去登录,因为前缀缺失最先弄坏的就是登录表单这个页面。

实时智能体查看器和实时编码查看器通过 Server-Sent Events 推流,而带缓冲的代理会把流一直压到结束才放出来。在这些路由上关掉缓冲:

nginx
location /app1/api/ {
    proxy_pass            http://127.0.0.1:8000/api/;
    proxy_set_header      Host $host;
    proxy_buffering       off;
    proxy_read_timeout    3600s;
}

Docker 部署

Potato 在 GitHub Container Registry 上发布了预构建镜像 ghcr.io/davidjurgens/potato。镜像里只有 Potato 及其依赖,没有别的东西。你的项目文件夹连同其中的 config.yaml 和数据挂载到 /app,所以同一个镜像可以用于每个任务:

bash
docker run -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_SECRET_KEY="$(openssl rand -hex 32)" \
  ghcr.io/davidjurgens/potato:latest

latest 标签和各个发布版本标签(例如 2.12.1)只包含核心依赖。-all 标签(latest-all、2.12.1-all)额外加入 AI SDK、格式读取器以及导出和认证相关的附加依赖,但不包含视觉模型。

容器用一个 gunicorn worker 和八个线程运行任务,原因见上面 gunicorn 一节。把 GUNICORN_WORKERS 设为大于 1 会让容器在启动时停止,除非同时设置了 POTATO_ALLOW_MULTIWORKER=1,而这样做在真实研究中会丢失标注。需要更高并发时请提高 GUNICORN_THREADS。POTATO_CONFIG 用来指定 config.yaml 以外的配置文件,PORT 把容器内的端口从 7860 改成别的值。

容器以 uid 1000 运行,并把标注输出、potato.log 以及它的 SQLite 数据库写进 /app。在 Linux 上,如果绑定挂载的项目文件夹属于别的用户,服务器会在启动时停止,并给出一条指明问题所在的错误。用 sudo chown -R 1000:1000 myproject 把文件夹交给 uid 1000。Mac 上的 Docker Desktop 会忽略绑定挂载的所有权,所以这个错误只会在 Linux 上出现。

镜像名之后的参数会替换服务器命令,这在做一次性检查时很有用:

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

Docker Compose

yaml
services:
  potato:
    image: ghcr.io/davidjurgens/potato:latest
    ports:
      - "8000:7860"
    volumes:
      - ./myproject:/app
    environment:
      - POTATO_SECRET_KEY=${POTATO_SECRET_KEY}
      - POTATO_ADMIN_API_KEY=${POTATO_ADMIN_API_KEY}
    restart: unless-stopped
 
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./certs:/etc/ssl/certs
    depends_on:
      - potato
    restart: unless-stopped

在 Compose 网络内部,nginx 通过服务名和容器端口访问 Potato,所以上面反向代理一节里的 upstream 块要写成 server potato:7860;,而不是 server 127.0.0.1:8000;。

由于项目文件夹是挂载进去的,而不是复制进镜像,Potato 写进去的标注在容器被替换后仍然留在宿主机上。

访问控制

限制谁可以注册

Potato 默认允许自助注册。在公开服务器上,请列出允许注册的人:

yaml
user_config:
  allow_all_users: false
  users:
    - annotator1@example.com
    - annotator2@example.com

这两个键都属于 user_config。写在顶层会被忽略,看上去就是一份放所有人进来的访问名单。

管理员访问

admin_api_key 是通过 X-API-Key 请求头发送的共享密钥。你不设置时 Potato 会生成一个并写入 {task_dir}/admin_api_key.txt,所以在生产主机上请自己设置,并且不要放进代码仓库:

yaml
admin_api_key: "your-shared-key"
 
rbac:
  enabled: true
  user_role_assignments:
    lead@example.com: admin

配了 rbac 之后,指定的人正常登录就能进 /admin,不必到处传那个共享密钥。完整的权限集合见用户管理。

会话

yaml
secret_key: "a-long-random-string"
session_timeout_minutes: 60
persist_sessions: true
session_lifetime_days: 7
max_session_seconds: 7200

secret_key 用来给会话 cookie 签名,也可以改由 POTATO_SECRET_KEY 提供。一个都没有时,Potato 每次启动都会生成新密钥,于是每次重启都会在所有人做到一半时把他们登出。在部署即重启的服务器上,这是最常让人丢标注的一个设置。

session_timeout_minutes 会清除空闲达到这么长时间的会话,每次请求都会让计时重新开始。Potato 2.11 是第一个真正执行这项设置的版本,默认值为 480 分钟。像这个示例一样设为 60 时,离开一小时的标注者回来后需要重新登录。

标注输出

yaml
output_annotation_dir: /var/potato/annotations/
export_annotation_format: [csv, jsonl]
auto_export_interval: 300

Potato 会在 output_annotation_dir 下为每名标注者写一个目录,各自存放该人的 user_state.json。export_annotation_format 指定周期性导出所写的格式,留空则完全不做自动导出。auto_export_interval 是两次导出之间的间隔秒数,默认是 60。

数据库后端

标注默认存在磁盘上。需要数据库的部署可以用 database.type,它接受 mysql 或 file:

yaml
database:
  type: mysql
  host: localhost
  port: 3306
  database: potato_db
  username: potato
  password: ${DB_PASSWORD}

键名是 database 和 username,不是 name 和 user。连接串也可以改由 POTATO_DB_CONNECTION 提供,这样凭据就不会留在配置文件里。

用 pip install 'potato-annotation[mysql]' 安装驱动。2.10.2 之前,MySQL 后端从未真正启用,指定了它的配置会悄无声息地把标注存到磁盘上。从 2.10.2 起,缺少驱动或无法连接服务器都会中止启动,导出、备份和 potato deploy pull 也会读取数据库。

健康检查与日志

Potato 在每种部署下都提供 GET /health,这个路由不需要认证,所以可以直接拿来给负载均衡器或编排系统做就绪探针:

bash
curl -sf https://annotation.example.com/health

日志详细程度在配置里或命令行上设置,而不是在某个 logging 块里:

yaml
verbose: true
debug_log: server

debug_log 接受 all、ui、server 或 none。当你在追查某一个子系统时,POTATO_LOGGERS 环境变量可以把输出收窄到指定的几个 logger。

扩展

Potato 以单进程运行,并把整个项目集放在内存里,所以要扩的是机器而不是服务器台数。在负载均衡器后面跑多个副本并不会把工作分摊开。每个副本都有自己那份分配队列和标注者状态,这会完整地重现上面 gunicorn 一节里说的数据丢失。

随数据集增长的资源是内存。到百万项目量级时,负载数据可以转到 SQLite 缓存:

yaml
item_store:
  backend: paged      # default: memory
  cache_size: 2048

开启之前先测量。分页在视觉类项目上大约省 28%、文本上大约省 51%,不是一个数量级的差别,而且它让语料加载慢大约 2.5 倍。那个文件是缓存,每次启动都会从数据文件重建,所以删掉它是安全的。

对大型项目来说,真正有效的杠杆是用 per_annotator_quota 限定任何一名标注者被分到多少,用 batch_assignment 把语料分给不同批次的人,以及用 data_directory 随时间陆续加入数据文件,而不是在启动时一次性全部载入。

备份策略

备份要覆盖的不只是标注输出目录。任务目录里还有最多三个 SQLite 数据库,而且它们都无法从数据文件重建:project.sqlite(备忘录、编码手册、审核流程)、datasets.sqlite,以及使用标注引擎时的 engine.sqlite。通过注册表单创建的账号,Potato 存放在 user_config.json 中。服务器写入期间数据库以 WAL 模式运行,所以用 tar 或 cp 复制数据库文件,得到的副本可能已损坏或缺少最近的工作,而这两个命令都不会提醒你。

内置备份

从 2.10 版开始,Potato 的 backup 块会把标注输出和数据库的 .backup 快照复制到服务器之外,并在服务器以空磁盘启动时恢复。恢复时账号列表会和标注一起恢复,而且从不覆盖磁盘上已有的标注。下面这个块每五分钟(默认间隔)把副本发送到一个 S3 存储桶,所以磁盘丢失时,标注者最多损失五分钟的工作:

yaml
backup:
  schedule_minutes: 5
  restore_on_boot: true
  sinks:
    - type: s3
      bucket: lab-potato-backups
      prefix: pilot-study
      endpoint_url: https://s3.example.edu   # leave out for AWS S3

endpoint_url 让备份目标指向任何支持 S3 API 的服务,比如大学的对象存储、MinIO、Cloudflare R2 或 Backblaze B2。凭据来自服务器环境中的 POTATO_S3_ACCESS_KEY_ID 和 POTATO_S3_SECRET_ACCESS_KEY,或者 boto3 通常的凭据链。只限于那一个存储桶的密钥可以限制服务器能访问的范围。改用带 repo_id 的 type: huggingface 备份目标,则会使用 HF_TOKEN 把副本发送到一个私有的 Hugging Face 数据集。两种备份目标都需要 hosting extra,即 pip install 'potato-annotation[hosting]'。

留在自己存储上的副本

如果数据使用协议要求数据留在服务器上,可以改用每晚运行的任务把数据复制到本地或挂载的存储。下面的脚本会打包输出目录,用 SQLite 的 .backup 命令为每个数据库做快照而不是直接复制文件,并保留七天的副本。它需要 sqlite3 命令行工具:

bash
#!/bin/bash
# backup.sh
set -e
TASK_DIR=/var/potato
DEST=/var/backups/potato/$(date +%Y%m%d_%H%M%S)
mkdir -p "$DEST"
 
tar -czf "$DEST/annotations.tar.gz" -C "$TASK_DIR" annotations
for db in project.sqlite datasets.sqlite engine.sqlite; do
  if [ -f "$TASK_DIR/$db" ]; then
    sqlite3 "$TASK_DIR/$db" ".backup '$DEST/$db'"
  fi
done
cp "$TASK_DIR/config.yaml" "$DEST/"
if [ -f "$TASK_DIR/user_config.json" ]; then cp "$TASK_DIR/user_config.json" "$DEST/"; fi
 
find /var/backups/potato -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} +

用 cron 在每晚凌晨 2 点运行它:

bash
0 2 * * * /var/potato/backup.sh

每晚一次的副本最多可能丢失一天的工作,所以在标注者很多的研究期间,请提高运行频率。研究开始之前,把一份副本恢复到测试服务器上,确认最后一条标注还在。

安全检查清单

  • 用有效证书在代理上终止 TLS
  • 设置 secret_key 或 POTATO_SECRET_KEY,让会话能熬过重启
  • 自己设置 admin_api_key,不要直接用生成出来的那个文件
  • 用 user_config.users 限制注册
  • 关掉 debug,因为调试模式会让反向代理的请求不用密钥就能进入管理页面
  • gunicorn 只用一个 worker
  • 凭据放环境变量里,不要放配置里
  • 只在可信代理后面才开启 POTATO_PROXY_FIX
  • 备份输出目录、账号文件和数据库,并测试恢复
  • 检查一下谁持有管理员角色

环境变量

bash
# .env file
POTATO_SECRET_KEY=your-super-secret-key
POTATO_ADMIN_API_KEY=your-admin-key
POTATO_DB_CONNECTION=mysql://potato:password@localhost:3306/potato_db

生成签名密钥:

bash
python3 -c 'import secrets; print(secrets.token_hex(32))'

在 Docker Compose 里加载:

yaml
services:
  potato:
    env_file:
      - .env

故障排查

查看日志

bash
# Gunicorn logs
journalctl -u potato -f
 
# Docker logs
docker-compose logs -f potato

常见问题

502 Bad Gateway:检查 Potato 是否在运行,以及它绑定的端口是不是代理转发过去的那个。

部署之后所有人都被登出:secret_key 没设置,启动时生成了新的签名密钥。

标注丢失:检查 worker 数量。多于一个 gunicorn worker 会丢弃标注而且不做任何提示。

路径前缀下静态资源 404:设置 POTATO_PROXY_FIX 或 POTATO_URL_PREFIX,并确认代理在转发之前把前缀去掉了。

语料很大时很慢:先加内存再加服务器,超过百万项目量级可以考虑 item_store.backend: paged。

下一步

实现细节见反向代理指南和扩展指南。