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,並檢查代理是否在轉發前去掉了字首。

延伸閱讀