Skip to content
指南阅读约需 11 分钟

在服务器上部署 Potato

在生产环境中用 nginx 加 TLS 运行 Potato,跑在 Docker 里或虚拟机上,以及为什么 Potato 服务器只跑一个进程而不是多个工作进程。

Potato Team

每个项目跑一个 Potato 进程,前面放上 nginx 和一张 TLS 证书;需要更高并发时,提高线程数而不是工作进程数。第二个工作进程会悄无声息地丢掉标注。

Potato 把数据项池、分配队列和每位标注者的状态都放在服务器进程的内存里。仅这一点就决定了部署上的大部分选择。正因为状态在进程内,Potato 服务器才是靠线程而不是靠工作进程扩展的,要备份的才是标注输出目录,把负载均衡分散到多个 Potato 进程上才会毁掉一项研究而不是让它更快。本文讲运行服务器的三种方式、放在它前面的反向代理和 TLS,以及真有标注者上来之后哪些配置键才重要。

为什么 Potato 服务器只跑一个进程

第二个 gunicorn 工作进程会拿到数据项池、分配队列和用户状态各自的一份副本。它会把第一个工作进程已经分配出去的数据项再发一遍;又因为 user_state.json 每次保存都是整个重写,最后保存的那个工作进程会把另一个的标注丢掉。日志里对此没有任何记录。标注就这么不见了,而最先发现的通常是某位标注者说,自己标过的一条数据现在看起来根本没被动过。

Potato 官方发布的 Docker 镜像强制了这一点。它只跑一个 gunicorn 工作进程,配置更多就拒绝启动;POTATO_ALLOW_MULTIWORKER=1 可以在不是真实研究的场合覆盖这条拒绝。并发改由线程提供,它们共享同一份状态。镜像通过 GUNICORN_THREADS 默认开八个线程,足以服务一项典型研究里几十位同时在线的标注者。

这条约束框住了本文其余的内容。每个项目跑一个进程,给它足够装下数据集的内存,反向代理用来做 TLS 和静态文件,而不是用来分摊负载。

运行服务器的三种方式

方式你能得到什么什么时候用
potato deploy按你的配置开好主机、TLS 和镜像今天就想跑起来的研究
Docker 镜像每次都是同样的字节,不用编译依赖你自己已经在管的机器
虚拟机加 systemd完全掌控主机和 Python 环境有自己一套规矩的机构服务器

用 potato deploy 走快车道

potato deploy 会按你已经有的配置文件开一台主机、装上镜像并配好 TLS:

bash
potato deploy up myproject/config.yaml --provider digitalocean

几分钟后你就有了一个可以发给标注者的 HTTPS 地址,potato deploy pull 再把标注拉回本地。从 2.10.0 起,可选的目标是 local、potato share 和十三个云主机,其中包括 AWS、Jetstream2、Hetzner、Fly 和 Railway,它们的区别在于数据能不能在重启后留下来。七个虚拟机目标同时具备持久磁盘、日志流和 SSH;会清空磁盘的主机需要 --backup,它会把标注复制到一个 HuggingFace 数据集或 S3 存储桶。在本地或云端运行 Potato对这些目标做了比较。先从 local 开始,它用同一套打包和同一个镜像运行,不需要账号也不产生账单,而且会在花钱之前告诉你将要部署的是什么。

官方发布的 Docker 镜像

Potato 会把预构建的镜像发布到 GitHub Container Registry。镜像里有服务器和它的依赖,你的项目在运行时挂载到 /app,所以一个镜像能服务所有任务:

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

这个镜像之所以存在,是因为 Potato 的核心依赖里有 numpy、pandas、scipy 和 scikit-learn。在小虚拟机上装这些东西,快也快不到哪去。1 GB 的机器上构建会因为内存不足被杀掉,2 GB 上每次重新部署都要花五到十分钟。而拉取镜像不到一分钟。

有两个标签值得关注。latest 带核心依赖,约 840 MB;latest-all 另外加上 AI SDK、文档格式、导出和 OAuth 登录,约 1.5 GB。两者都为 linux/amd64 和 linux/arm64 构建。要跑好几个月的研究请固定版本号而不是用 latest,因为 latest 会在你脚下变。

只要部署要活过一次运行,就设置 POTATO_SECRET_KEY。不设的话,Flask 的会话签名密钥每个进程都是随机的,于是每次重启都会把所有标注者登出。POTATO_CONFIG 指定容器内的配置文件路径,PORT 设置监听端口(默认 7860),GUNICORN_TIMEOUT 以秒为单位设置工作进程超时。

镜像以 uid 1000 运行,而绑定挂载会保留宿主机上的属主,所以在 Linux 上容器往往写不了你创建的目录。服务器在启动时会检查这一点并直接拒绝运行,而不是启动到一半才崩掉。要么用 sudo chown -R 1000:1000 myproject 把目录交给容器里的用户,要么用 docker run --user "$(id -u):$(id -g)" 以你自己的身份运行,这样标注文件的属主也还是你。macOS 和 Windows 上的 Docker Desktop 会忽略绑定挂载上的属主,所以在 Mac 上跑得好好的一套配置,到了 Linux 服务器上照样可能失败。

在虚拟机上安装

在你直接管理的机器上,把 Potato 装进一个虚拟环境,并把进程交给 systemd 管。

bash
sudo apt update
sudo apt install python3 python3-venv python3-pip nginx certbot python3-certbot-nginx
 
sudo mkdir -p /opt/potato/projects
cd /opt/potato
python3 -m venv venv
source venv/bin/activate
pip install potato-annotation gunicorn
potato --version

用 gunicorn 跑这个应用,一个工作进程加若干线程。应用工厂在 potato.flask_server:create_app,参数是配置文件路径:

bash
gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
  --chdir /opt/potato/projects/my_project \
  "potato.flask_server:create_app('config.yaml')"

创建 /etc/systemd/system/potato.service,让服务器在重启或崩溃之后能自己回来:

ini
[Unit]
Description=Potato Annotation Server
After=network.target
 
[Service]
Type=simple
User=potato
Group=potato
WorkingDirectory=/opt/potato/projects/my_project
Environment="PATH=/opt/potato/venv/bin"
EnvironmentFile=/opt/potato/.env
ExecStart=/opt/potato/venv/bin/gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
  "potato.flask_server:create_app('config.yaml')"
Restart=always
RestartSec=10
 
[Install]
WantedBy=multi-user.target

启用并启动它:

bash
sudo systemctl enable potato
sudo systemctl start potato
sudo systemctl status potato

EnvironmentFile 里放那些不该写进配置文件的密钥:

bash
# /opt/potato/.env
POTATO_SECRET_KEY=a-long-random-value
OPENAI_API_KEY=sk-...

用 openssl rand -hex 32 生成一次签名密钥,然后在整项研究期间一直用它。改动它会让所有会话失效,把标注者全部登出。

nginx 与 TLS

Potato 绑定在 localhost 上,nginx 在它前面终结 TLS。上传大小限制对图像和音频项目很重要,默认 1 MB 的请求体上限会拒掉媒体文件,而标注者根本看不到这次失败。

nginx
server {
    listen 443 ssl;
    http2 on;
    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;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
 
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
 
    client_max_body_size 100M;
 
    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}

启用站点并申请证书:

bash
sudo ln -s /etc/nginx/sites-available/potato /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d annotation.example.com
sudo systemctl enable certbot.timer

在防火墙上把其余的口子全部关掉,因为一旦前面有了代理,Potato 的端口就不能再被直接访问:

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable

在一个主机名下提供多个项目

机构常常把路径映射到本地端口,用一个公网 HTTPS 入口暴露多台 Potato 服务器。标注界面通过根相对 URL 请求静态资源和保存数据,所以挂在 /app1 下的服务器必须知道自己的前缀,否则 CSS 会 404,自动保存会以"annotations not saved"失败。Potato 从 WSGI 的 SCRIPT_NAME 读取这个前缀,设置它有两种办法。

如果代理归你管、并且它能发送 X-Forwarded-Prefix,就用 POTATO_PROXY_FIX=1。如果代理的配置不是你能改的,就用 POTATO_URL_PREFIX=/app1。无论哪种办法,代理都必须在转发之前把前缀去掉,让 Flask 继续收到不带前缀的路径:

nginx
location /app1/ {
    proxy_pass         http://127.0.0.1:8000/;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   X-Forwarded-Prefix /app1;
}

ProxyFix 会信任转发过来的请求头,所以只有当应用只能通过受信任的代理访问时,才启用 POTATO_PROXY_FIX。如果内部端口同时可以被直接访问,客户端就能伪造前缀,污染 Potato 生成的 URL。

生产项目需要的配置

把生产配置和本地配置区分开的是四项设置:一个稳定的签名密钥、强制密码、明确的会话有效期,以及一个你有备份的输出目录。

yaml
annotation_task_name: "Production Annotation Task"
task_description: "Classify each passage."
 
host: 127.0.0.1
port: 8000
 
# Sessions survive a restart only when the signing key is stable.
# Set it here, or leave it out and supply POTATO_SECRET_KEY in the environment.
require_password: true
persist_sessions: true
session_timeout_minutes: 480
 
data_files:
  - data/items.jsonl
 
item_properties:
  id_key: id
  text_key: text
 
output_annotation_dir: annotation_output/production/
export_annotation_format: jsonl
 
user_config:
  allow_all_users: false
  users:
    - annotator1
    - annotator2
 
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What is the sentiment expressed in this text?"
    labels:
      - name: positive
      - name: neutral
      - name: negative
    label_requirement:
      required: true

session_timeout_minutes 限制的是空闲时间而不是工作时长,因为每次请求都会把计时重置。默认的 480 适合工作一整天的标注者;机器是共用的时候把它调小。只要服务器能从公网访问,就设置 allow_all_users: false 并列出你的标注者,这样陌生人即便找到了网址,也没法注册进来往你的数据集里写东西。

健康检查与日志

/health 是给容器和负载均衡器用的免认证存活探针,它有意几乎什么都不报告,因为它说的每一句话都是公开的:

bash
curl http://localhost:8000/health
# {"status": "ok"}          once the state managers exist
# {"status": "starting"}    503 while the server is still coming up

容器的 healthcheck 和部署服务商创建后的轮询都以这个状态切换为准。任务名称、数据项数量和标注者数量要从 /admin/health 拿,那个接口需要管理员 API 密钥。

日志都走进程本身,所以 systemd 能捕获到:

bash
sudo journalctl -u potato -f

在配置里用 verbose: true 提高日志级别,或者用 debug_log 只打开你需要的那半边调试日志,它的取值是 all、ui、server 或 none。真实研究期间两个都别开,因为调试日志足够啰嗦,长时间运行能把一块小磁盘写满。

需要备份什么

项目目录里有三样东西是重建不出来的。output_annotation_dir 下的标注输出就是这项研究本身。project.sqlite 保存备忘、编码手册、案例、搜索索引和审核流程。datasets.sqlite 保存数据集记录。其余的东西都能从你的数据文件和配置重新生成。

bash
#!/bin/bash
# /opt/potato/backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/opt/potato/backups
 
tar -czf $BACKUP_DIR/annotations_$DATE.tar.gz \
  /opt/potato/projects/*/annotation_output/ \
  /opt/potato/projects/*/project.sqlite \
  /opt/potato/projects/*/datasets.sqlite \
  /opt/potato/projects/*/config.yaml
 
find $BACKUP_DIR -mtime +7 -delete

用 crontab 每晚跑一次,并把归档拷到这台机器之外:

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

机器该配多大

随数据集增长的资源是内存,因为数据项池放在进程里。其他东西都改成了惰性加载,所以嵌入和 sentence-transformers 那一套在启动时不会被导入,除非某个功能用得上。在 v2.6.0 发布说明所用的参考数据集上,常驻内存从约 750 MB 降到约 365 MB,5 万条数据项的启动时间从约 10 秒降到约 5.7 秒。

每条数据项的开销很小,而且是能测出来的。一条包含图片 URL 和两个字段的视觉数据项约占 932 字节,一条约 540 个不重复字符的文本数据项约占 1430 字节。所以一百万条文本数据项约占 1.43 GB 常驻内存,按这个数字做预算。

对于大到需要在意这一点的语料,数据项内容可以存进一个 SQLite 文件,只在内存里留一小块缓存:

yaml
item_store:
  backend: paged
  cache_size: 2048

把这个配置块加到上面的配置里。开启之前先看清楚实测数字。分页省下的是视觉数据项 28%、文本 51%,而不是一个数量级,代价是加载语料要多花约 2.5 倍的时间。不到一百万条数据项时,它换来的只是一台更慢的服务器,没有任何好处。

宁可换一台内存更大的机器,也不要把一个项目拆到多台服务器上,因为那种拆分就是本文开头讲的多进程故障。如果一台机器真的装不下语料,那就把工作切片。批量分配给每位标注者一份切片,per_annotator_quota 限制任何一个人最多被分到多少,配上目录监听的 data_directory 让你在服务器运行期间继续添加文件。

排查问题

端口已被占用。 找到那个进程把它停掉,而不是在另一个端口上针对同一个项目目录再起一个 Potato:

bash
sudo lsof -i :8000

项目目录权限被拒。 服务用户需要拥有这个目录,在 Docker 里这个用户是 uid 1000:

bash
sudo chown -R potato:potato /opt/potato

每次重启标注者都被登出。 缺少 POTATO_SECRET_KEY 或配置里的 secret_key,所以签名密钥每次启动都重新生成。

代理后面静态资源 404、自动保存失败。 服务器不知道自己的 URL 前缀。设置 POTATO_PROXY_FIX=1 或 POTATO_URL_PREFIX,并检查代理是否在转发前去掉了前缀。

延伸阅读