在伺服器上部署 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 上開展標註研究,伺服器可以訪問之後