Skip to content
ガイド15分で読めます

サーバーへのPotatoのデプロイ

nginxとTLSの背後でPotatoを本番運用する方法をDockerとVMの両方について説明し、Potatoサーバーを複数ワーカーではなく単一プロセスで動かす理由も扱います。

Potato Team

プロジェクトごとにPotatoのプロセスを1つだけ動かし、その前段にnginxとTLS証明書を置き、同時実行数を増やしたいときはワーカープロセスではなくスレッドを増やしてください。2つ目のワーカープロセスを追加すると、警告なしにアノテーションが失われます。

Potatoは、アイテムプール、割り当てキュー、各アノテーターの状態を、サーバープロセス内のメモリに保持しています。デプロイに関する判断の大半は、この一点で決まります。状態がプロセス内にあるから、Potatoサーバーはワーカーではなくスレッドでスケールし、バックアップすべき対象はアノテーション出力ディレクトリになり、複数のPotatoプロセスへの負荷分散は処理を速めるどころか研究データを壊します。本ガイドでは、サーバーを動かす3つの方法、その前段に置くリバースプロキシとTLS、そして実際のアノテーターが使い始めてから重要になる設定キーを扱います。

Potatoサーバーが単一プロセスで動く理由

2つ目のgunicornワーカーは、アイテムプール、割り当てキュー、ユーザー状態について独自のコピーを持ちます。このワーカーは、1つ目のワーカーがすでに割り当てたインスタンスを配ってしまいます。さらにuser_state.jsonは保存のたびに全体が書き直されるため、最後に保存したワーカーがもう一方のワーカーのアノテーションを消してしまいます。ログにはこのことが何も記録されません。アノテーションが消えても、最初に気づくきっかけはたいてい、ラベルを付けたはずのアイテムが手つかずに見えるというアノテーターからの報告です。

Potatoが公開しているDockerイメージは、この制約を強制します。gunicornワーカーを1つだけ実行し、それより多い数では起動を拒否します。POTATO_ALLOW_MULTIWORKER=1は、実際の研究ではない用途のためにこの拒否を解除します。同時実行性は代わりにスレッドで確保し、スレッドは状態の単一のコピーを共有します。イメージはGUNICORN_THREADSによってデフォルトで8スレッドを使い、一般的な研究で同時に作業する数十人のアノテーターに対応できます。

この制約が、本ガイドの残りの内容の前提になります。プロジェクトごとに1プロセスを動かし、データセットに見合うメモリを与え、リバースプロキシは負荷分散ではなくTLSと静的ファイルのために使ってください。

サーバーを動かす3つの方法

方法得られるもの使いどころ
potato deploy設定ファイルからホスト、TLS、イメージを用意する今日中に動かしたい研究
Dockerイメージ毎回まったく同じ中身で、依存関係のビルドが不要すでに管理しているマシン
VMとsystemdホストとPython環境を完全に制御できる独自の規則がある機関のサーバー

最短経路としてのpotato deploy

potato deployは、手元にある設定ファイルからホストを用意し、イメージをインストールし、TLSを構成します:

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

数分後にはアノテーターに送るHTTPSのURLが手に入り、potato deploy pullでアノテーションを手元に取得できます。2.10.0以降、デプロイ先はlocal、potato shareと13のクラウドホストで、AWS、Jetstream2、Hetzner、Fly、Railwayもその中に含まれます。再起動後にデータが残るかどうかはそれぞれ異なります。7つのVMデプロイ先は永続ディスク、ログのストリーミング、SSHをすべて備えており、ディスクが消去されるホストでは--backupが必要です。--backupはアノテーションをHuggingFaceのデータセットまたはS3バケットにコピーします。比較はPotato をローカルまたはクラウドで実行するにあります。まずはlocalから始めてください。こちらはアカウントも請求もなしに同じバンドルと同じイメージを実行し、費用が発生する前に何がデプロイされるかを示します。

公開Dockerイメージ

Potatoは、ビルド済みのイメージをGitHub Container Registryで公開しています。イメージにはサーバーとその依存関係が含まれ、プロジェクトは実行時に/appにマウントされるため、1つのイメージですべてのタスクに対応できます:

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が含まれるからです。これらを小さなVMにインストールすると、うまくいっても時間がかかります。1 GBのマシンではメモリ不足でビルドが強制終了され、2 GBでは再デプロイのたびに5〜10分かかります。イメージのpullは1分もかかりません。

重要なタグは2つあります。latestはコア依存関係を含み約840 MBで、latest-allはAI SDK、ドキュメント形式、エクスポート、OAuthログインを加えて約1.5 GBです。どちらもlinux/amd64とlinux/arm64向けにビルドされています。latestは知らないうちに中身が変わるので、数か月続く研究ではlatestではなくバージョンを固定してください。

1回の実行より長く使うものには、必ず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サーバーでは失敗することがあります。

VMへのインストール

直接管理しているマシンでは、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のもとで、ワーカー1つと複数のスレッドでアプリを実行します。アプリケーションファクトリーは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

1つのホスト名で複数のプロジェクトを提供する

機関では、パスをローカルポートに対応付けて、複数のPotatoサーバーを1つの公開HTTPSエンドポイントから提供することがよくあります。アノテーションUIはアセットの取得と保存にルート相対URLを使うため、/app1にマウントしたサーバーは自分のプレフィックスを知っている必要があります。知らないとCSSが404になり、自動保存が「annotations not saved」で失敗します。PotatoはプレフィックスをWSGIのSCRIPT_NAMEから読み取り、その設定方法は2つあります。

プロキシを自分で管理していて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を汚染できてしまいます。

本番プロジェクトに必要な設定

本番用の設定とローカル用の設定を分けるのは、4つの項目です。安定した署名鍵、パスワードの要求、明示的なセッション有効期間、そしてバックアップを取ってある出力ディレクトリです。

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を設定してアノテーターを列挙してください。そうすれば、URLを見つけた第三者が登録してデータセットに書き込み始めることはできません。

ヘルスチェックとログ

/healthは、コンテナやロードバランサー向けの認証不要な死活監視エンドポイントです。返す内容はすべて公開されるため、意図的にほとんど何も報告しません:

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

コンテナのヘルスチェックも、デプロイプロバイダーが作成後に行うポーリングも、この状態の遷移を基準にしています。タスク名、アイテム数、アノテーター数は、代わりに/admin/healthから取得します。こちらには管理用APIキーが必要です。

ログはプロセスを通して出力されるので、systemdがそれを取得します:

bash
sudo journalctl -u potato -f

設定でverbose: trueを指定するとログレベルが上がります。またdebug_logで必要な部分のデバッグログだけを有効にでき、all、ui、server、noneのいずれかを指定します。デバッグログは長期間の運用で小さなディスクを埋めるほど量が多いので、実際の研究中はどちらもオフにしておいてください。

バックアップの対象

プロジェクトディレクトリの中には、再生成できないものが3つあります。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に減り、50,000アイテムでの起動時間は約10秒から約5.7秒に短縮されました。

アイテムあたりのコストは小さく、測定できます。画像URLと2つのフィールドを持つビジョンアイテムは約932バイト、ユニークな文字をおよそ540含むテキストアイテムは約1430バイトです。したがって100万件のテキストアイテムは常駐メモリで約1.43 GBになり、予算を立てるときはこの数字を基準にします。

これが問題になるコーパスでは、アイテムのペイロードを、小さなメモリ内キャッシュを持つSQLiteファイルに置けます:

yaml
item_store:
  backend: paged
  cache_size: 2048

このブロックを上の設定に追加します。有効にする前に、測定結果を読んでください。ページングで節約できるのはビジョンアイテムで28%、テキストで51%であり、桁違いの削減ではありません。その代わりに、コーパスの読み込みには約2.5倍の時間がかかります。100万アイテム未満では、サーバーが遅くなるだけで利点はありません。

プロジェクトを複数のサーバーに分割するより、RAMの多いマシンを選んでください。分割は、本ガイドの冒頭で説明したマルチプロセスの失敗そのものだからです。1台のマシンでどうしてもコーパスを保持できない場合は、代わりに作業を分割します。バッチ割り当てで各アノテーターに一部分を渡し、per_annotator_quotaで1人あたりの割り当て量に上限を設け、ディレクトリ監視付きのdata_directoryを使えば、サーバーの稼働中にファイルを追加できます。

トラブルシューティング

ポートがすでに使われている。 同じプロジェクトディレクトリに対して別のポートで2つ目の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を設定し、プロキシが転送前にプレフィックスを取り除いているか確認してください。

関連資料