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。

下一步

實現細節見反向代理指南和擴充套件指南。