在服务器上部署 Potato
在生产环境中用 nginx 加 TLS 运行 Potato,跑在 Docker 里或虚拟机上,以及为什么 Potato 服务器只跑一个进程而不是多个工作进程。
每个项目跑一个 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:
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,所以一个镜像能服务所有任务:
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 管。
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,参数是配置文件路径:
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,让服务器在重启或崩溃之后能自己回来:
[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启用并启动它:
sudo systemctl enable potato
sudo systemctl start potato
sudo systemctl status potatoEnvironmentFile 里放那些不该写进配置文件的密钥:
# /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 的请求体上限会拒掉媒体文件,而标注者根本看不到这次失败。
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;
}
}启用站点并申请证书:
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 的端口就不能再被直接访问:
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 继续收到不带前缀的路径:
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。
生产项目需要的配置
把生产配置和本地配置区分开的是四项设置:一个稳定的签名密钥、强制密码、明确的会话有效期,以及一个你有备份的输出目录。
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: truesession_timeout_minutes 限制的是空闲时间而不是工作时长,因为每次请求都会把计时重置。默认的 480 适合工作一整天的标注者;机器是共用的时候把它调小。只要服务器能从公网访问,就设置 allow_all_users: false 并列出你的标注者,这样陌生人即便找到了网址,也没法注册进来往你的数据集里写东西。
健康检查与日志
/health 是给容器和负载均衡器用的免认证存活探针,它有意几乎什么都不报告,因为它说的每一句话都是公开的:
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 能捕获到:
sudo journalctl -u potato -f在配置里用 verbose: true 提高日志级别,或者用 debug_log 只打开你需要的那半边调试日志,它的取值是 all、ui、server 或 none。真实研究期间两个都别开,因为调试日志足够啰嗦,长时间运行能把一块小磁盘写满。
需要备份什么
项目目录里有三样东西是重建不出来的。output_annotation_dir 下的标注输出就是这项研究本身。project.sqlite 保存备忘、编码手册、案例、搜索索引和审核流程。datasets.sqlite 保存数据集记录。其余的东西都能从你的数据文件和配置重新生成。
#!/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 每晚跑一次,并把归档拷到这台机器之外:
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 文件,只在内存里留一小块缓存:
item_store:
backend: paged
cache_size: 2048把这个配置块加到上面的配置里。开启之前先看清楚实测数字。分页省下的是视觉数据项 28%、文本 51%,而不是一个数量级,代价是加载语料要多花约 2.5 倍的时间。不到一百万条数据项时,它换来的只是一台更慢的服务器,没有任何好处。
宁可换一台内存更大的机器,也不要把一个项目拆到多台服务器上,因为那种拆分就是本文开头讲的多进程故障。如果一台机器真的装不下语料,那就把工作切片。批量分配给每位标注者一份切片,per_annotator_quota 限制任何一个人最多被分到多少,配上目录监听的 data_directory 让你在服务器运行期间继续添加文件。
排查问题
端口已被占用。 找到那个进程把它停掉,而不是在另一个端口上针对同一个项目目录再起一个 Potato:
sudo lsof -i :8000项目目录权限被拒。 服务用户需要拥有这个目录,在 Docker 里这个用户是 uid 1000:
sudo chown -R potato:potato /opt/potato每次重启标注者都被登出。 缺少 POTATO_SECRET_KEY 或配置里的 secret_key,所以签名密钥每次启动都重新生成。
代理后面静态资源 404、自动保存失败。 服务器不知道自己的 URL 前缀。设置 POTATO_PROXY_FIX=1 或 POTATO_URL_PREFIX,并检查代理是否在转发前去掉了前缀。
延伸阅读
- 本站的生产环境部署和反向代理
- 在 Docker 中运行 Potato,镜像参考
- 扩展与大数据集,支撑上述规格建议的实测数据
- 在 Prolific 上开展标注研究,服务器可以访问之后