跳轉到內容

在 Docker 中執行 Potato

用已釋出的 Docker 映像執行 Potato 項目,把標註儲存在你自己的磁碟上,並設定容器讀取的管理員金鑰和環境變數。

本頁介紹如何在容器中執行 Potato 標註項目,從第一條 docker run 到把正在進行的研究遷到新版本。本頁寫給在自己的機器或伺服器上執行研究、又不想在主機上安裝 Python 的研究人員和實驗室管理員。Potato 把映像釋出在 GitHub Container Registry,地址是 ghcr.io/davidjurgens/potato,映像裡只有伺服器及其依賴,沒有別的內容。容器啟動時你的項目資料夾掛載到 /app,所以同一個映像適用於所有任務。

potato deploy 會在該命令所配置的雲主機上運行同一個映像,並選擇與你已安裝的 Potato 版本一致的標籤。這條路線見在本地或雲端執行 Potato,在你自己管理的伺服器上使用 gunicorn、反向代理和 TLS 見生產部署。

獲取映像

用 pip 安裝 Potato 依賴的 numpy、pandas、scipy 和 scikit-learn,在 1 GB 記憶體的機器上會耗盡記憶體,在 2 GB 的機器上要五到十分鐘。映像裡已經裝好了這些依賴:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0

映像有兩個變體,每個變體都有一個滾動標籤、一個按釋出版本的標籤和一個按提交的標籤:

標籤內容
latest、2.14.0Potato 的核心依賴、備份客戶端(huggingface_hub 和 boto3),以及用於 SSO 登入的 Authlib
latest-all、2.14.0-all上述全部內容,再加上 all 可選依賴:ai_support 背後的 AI SDK、PDF 和 DOCX 匯入、parquet 匯出以及 MySQL 驅動
sha-<short>、sha-<short>-allmaster 分支的某一次提交

兩個變體都為 linux/amd64 和 linux/arm64 構建,arm64 版本可以在 Apple Silicon 上原生執行。兩個變體都不包含 vision 可選依賴及其數 GB 的 torch 包。瀏覽器端分割不需要這個可選依賴,因為 ONNX Runtime Web 已隨 Potato 包一起提供。

任何會執行超過一天的研究都應固定版本標籤。latest 標籤在 master 分支每次變更時都會重新構建,相隔一週的兩次 docker pull 可能取到不同的程式碼。2.14.0 這樣的版本標籤在釋出時推送一次,之後不會重新構建。

自己構建映像

當你需要兩個已釋出變體都不包含的可選依賴時,從倉庫的克隆構建映像。POTATO_EXTRAS 構建參數可以取 Potato 的 setup.py 中列出的任一可選依賴,也可以用逗號分隔取多個:

bash
git clone https://github.com/davidjurgens/potato
cd potato
docker build -t potato .
docker build --build-arg POTATO_EXTRAS=all -t potato:all .
docker build --build-arg POTATO_EXTRAS=all,saml -t potato:all-saml .

用於 SAML 登入的 saml 可選依賴不包含在 all 中。只有 POTATO_EXTRAS 中寫了 saml 時,Dockerfile 才會安裝該可選依賴編譯所需的 libxmlsec1 庫。本頁寫 ghcr.io/davidjurgens/potato:2.14.0 的地方,如果用的是本地構建的映像,請換成它的標籤,例如 potato:all。

執行項目

項目資料夾裡有 config.yaml 和配置中提到的所有檔案。本頁的示例使用一個含三個條目的情感標註任務,目錄結構如下:

text
myproject/
├── config.yaml
└── data/
    └── items.jsonl

配置中的所有路徑都相對於項目資料夾:

yaml
annotation_task_name: "Sentiment pilot"
task_dir: .
data_files:
  - data/items.jsonl
item_properties:
  id_key: id
  text_key: text
output_annotation_dir: annotation_output/
user_config:
  allow_all_users: true
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What is the sentiment of this sentence?"
    labels: [positive, neutral, negative]

data/items.jsonl 的每一行是一個條目:

json
{"id": "1", "text": "The battery lasts all day."}
{"id": "2", "text": "The screen cracked within a week."}
{"id": "3", "text": "It arrived on Tuesday."}

在包含 myproject 的資料夾中啟動伺服器:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0

任務隨後位於 http://localhost:8000。-p 8000:7860 參數把你機器上的 8000 埠對映到容器內伺服器監聽的 7860 埠。-v 參數把項目資料夾掛載到 /app。主機一側必須是 $PWD/myproject 這樣的路徑,因為 Docker 會把 myproject 這樣的裸名稱當作一個新建的空卷(volume)的名字。docker logs -f potato 持續檢視伺服器日誌,docker stop potato 停止伺服器。

含有多份配置的項目用 POTATO_CONFIG 選擇其中一份,取值是相對於 /app 的路徑:

bash
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_CONFIG=configs/pilot.yaml \
  ghcr.io/davidjurgens/potato:2.14.0

執行持續數週的研究

持續數週的研究需要一個在重啟和更新之間保持不變的會話簽名金鑰。沒有 POTATO_SECRET_KEY 時,伺服器每次啟動都會隨機生成新金鑰,並讓所有標註者退出登入。在項目資料夾之外建立一次環境變數檔案,之後每條 docker run 都傳入同一個檔案:

bash
printf 'POTATO_SECRET_KEY=%s\nPOTATO_ADMIN_API_KEY=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > potato.env
chmod 600 potato.env
 
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

該檔案第二行的作用見容器中的管理員金鑰一節。--restart unless-stopped 會在崩潰或主機重啟後讓容器自動恢復。

映像中的其他命令

映像名稱之後的任何內容都會取代伺服器。可以用映像自帶的校驗器檢查配置,也可以在容器裡開啟一個 shell:

bash
docker run --rm -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 potato validate config.yaml --strict
 
docker run --rm -it -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 sh

Linux 上的檔案所有權

映像中的伺服器以 uid 1000 執行,而繫結掛載保留資料夾在主機上的所有權。在 Linux 上,你自己建立的資料夾屬於你自己的 uid,由 root 建立的資料夾屬於 root。除非資料夾的所有者是 uid 1000,否則伺服器無法把標註寫入該資料夾。容器會在伺服器啟動前檢查資料夾,並以 ERROR: /app is not writable by uid 1000 停止,而不是在啟動途中失敗。下面兩條命令任選其一都能解決這個問題:

bash
sudo chown -R 1000:1000 myproject                  # hand the folder to the container user
docker run --user "$(id -u):$(id -g)" ...          # or run the container as yourself

使用 --user 時,Potato 寫出的標註和管理員金鑰檔案也屬於你自己的賬號。macOS 和 Windows 上的 Docker Desktop 會忽略繫結掛載上的所有權,所以在 Mac 上能執行的項目仍可能在 Linux 伺服器上失敗。POTATO_ALLOW_READONLY_APP=1 會跳過這項檢查。Potato 仍會在項目內寫入標註、potato.log 和它的 SQLite 資料庫,所以只有在這些路徑通過單獨的掛載可寫時才設定該變數。

有些主機不管映像如何指定,都以 root 啟動每個容器。容器以 root 啟動時,會把 /app 交給映像的 potato 使用者,並以該使用者啟動伺服器。設定 POTATO_RUN_AS_ROOT=1 則保持以 root 執行。

儲存標註

Potato 把研究收集的一切都寫入項目資料夾。標註位於 output_annotation_dir 之下,每位標註者一個目錄,裡面是該標註者的 user_state.json。config.yaml 旁邊有兩個 SQLite 資料庫。project.sqlite 儲存備忘、編碼本、案例、搜尋索引和審閱工作流,datasets.sqlite 儲存評估資料集及其版本。這兩個資料庫都無法從資料檔案重建。使用繫結掛載時,所有這些檔案都在你的磁碟上,容器被刪除後仍然保留。

output_annotation_dir 必須解析到項目資料夾之內。/data/annotations 這樣的路徑會讓伺服器在啟動時停止,並給出資訊 output_annotation_dir: Path '/data/annotations' resolves to '/data/annotations' which is outside the project directory '/app'。請使用 annotation_output/ 這樣的相對路徑。映像的 /data 卷同樣在項目之外,Potato 不會向其中寫入任何內容。

要從執行中的容器複製資料庫,請使用 SQLite 的備份命令,不要把它當作普通檔案複製。伺服器寫入資料庫期間,project.sqlite 以 WAL 模式執行,普通複製得到的檔案可能已損壞,或者缺少最近的工作,而且不會有任何提示。映像包含 sqlite3 工具,用於生成快照:

bash
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project.sqlite

大多數託管容器的主機會在容器重啟時清空容器的檔案系統。在這類主機上,請把項目放在掛載到 /app 的持久儲存上,或者在配置中加入 backup 塊。該塊會按計劃把標註和資料庫快照複製到 S3 儲存桶或 Hugging Face 資料集,並在伺服器以空磁碟啟動時恢復它們。映像的每個變體都包含該塊所需的客戶端。

環境變數

容器在啟動時讀取這些變數:

變數預設值用途
POTATO_CONFIGconfig.yaml配置在容器內的路徑,相對於 /app
PORT7860伺服器在容器內監聽的埠
GUNICORN_WORKERS1工作程序數;任何其他值都會讓容器在啟動時停止
GUNICORN_THREADS8這一個工作程序中的執行緒數,也就是同時處理的請求數
GUNICORN_TIMEOUT120工作程序可以保持無響應的秒數,超過後 gunicorn 會重啟該程序
POTATO_SECRET_KEY每次啟動隨機生成為會話 cookie 簽名;配置中的 secret_key 優先
POTATO_ADMIN_API_KEY生成的檔案管理員 API 金鑰;配置中的 admin_api_key 優先
POTATO_GENERATED_TEMPLATES_DIR啟動時選定Potato 寫入啟動時生成的任務模板的位置
POTATO_BUNDLE_URL未設定項目 .tar.gz 的 URL,啟動時下載到 /app
POTATO_BUNDLE_SHA256未設定下載的項目必須匹配的校驗和
POTATO_BUNDLE_TOKEN未設定作為 Authorization: Bearer 隨下載請求傳送
POTATO_RUN_AS_ROOT未設定容器以 root 啟動時,1 讓伺服器繼續以 root 執行
POTATO_ALLOW_MULTIWORKER未設定1 允許 GUNICORN_WORKERS 超過 1,這在真實研究中會丟失標註
POTATO_ALLOW_READONLY_APP未設定1 跳過啟動時對 /app 可寫的檢查

修改了 PORT 的容器,在 -p 的右側也需要同一個埠,例如 PORT=9000 對應 -p 8000:9000。

工作程序數和執行緒數

容器拒絕以多於一個 gunicorn 工作程序啟動。Potato 把條目池、分配佇列和每位標註者的狀態都放在一個程序的記憶體裡。第二個工作程序會拿到這三者各自的一份副本,並把第一個程序已經分配出去的條目再次分配。每次儲存都會整份重寫 user_state.json,最後儲存的那個程序會在沒有任何報錯的情況下丟棄另一個程序的標註。要處理更多併發請求,請提高 GUNICORN_THREADS,因為執行緒共享同一份狀態。

生成的模板

Potato 在啟動時為每個任務的標註頁面構建模板。在容器之外,只要安裝包的目錄可寫,模板就寫入該目錄。映像以 root 安裝 Potato,卻以 uid 1000 執行伺服器,所以伺服器無法寫入安裝包目錄。於是 Potato 把模板寫到 /tmp 下一個只有伺服器使用者可讀寫的目錄,啟動日誌會給出該目錄的名稱。Potato 在每次啟動時根據配置重新構建模板,丟失這個目錄不會有任何損失。設定 POTATO_GENERATED_TEMPLATES_DIR 可以把模板放到別處,例如 tmpfs 掛載。

啟動時下載項目

Render、Fly、Railway 和 ECS 這類只執行映像的主機,沒有可以掛載的資料夾。設定了 POTATO_BUNDLE_URL 時,容器會下載項目的 .tar.gz,用 POTATO_BUNDLE_SHA256 校驗下載內容,並在伺服器啟動前把項目解壓到 /app。容器把校驗和寫入 /app/.potato-bundle-sha,在持久磁碟上重啟時,如果校驗和相符就會跳過下載。在 potato deploy 構建的打包中,新版本的項目絕不會替換磁碟上已有的標註輸出或 SQLite 資料庫。potato deploy up 會為你設定這些變數。

容器中的管理員金鑰

/admin 的管理面板和管理員 API 使用同一個共享金鑰,通過 X-API-Key 請求頭髮送,或者輸入到管理員登入頁面。Potato 先讀取配置頂層的 admin_api_key,再讀取 POTATO_ADMIN_API_KEY 環境變數。在容器中,使用環境變數可以讓金鑰不出現在 config.yaml 裡,也不會進入存放項目的任何倉庫。執行持續數週的研究一節中的 potato.env 檔案設定了這個變數,管理員金鑰就是該檔案中儲存的值:

bash
grep POTATO_ADMIN_API_KEY potato.env

當配置和環境變數都沒有設定金鑰時,Potato 會在有人第一次開啟 /admin 或呼叫管理員端點時生成一個金鑰,並寫入任務目錄中的 admin_api_key.txt。在容器裡,任務目錄是 /app,也就是掛載的項目資料夾。該檔案只有伺服器的使用者可讀,重啟後 Potato 讀取的是同一個檔案。伺服器日誌會給出檔名,但不會列印金鑰。請通過容器讀取金鑰:

bash
docker exec potato cat /app/admin_api_key.txt

在 Linux 主機上,如果你把項目資料夾交給了 uid 1000,你自己的賬號不使用 sudo 就讀不了 admin_api_key.txt。用 --user "$(id -u):$(id -g)" 啟動的容器則會以你自己的賬號寫入該檔案。該金鑰能開啟什麼,見管理面板頁面。

容器中的除錯模式

在任何為研究提供服務的容器裡,都請保持 debug: false。配置中設定 debug: true 時,Potato 會改變兩個功能的行為:

  • 無需金鑰的管理頁面。 對於來自迴環地址、且不帶 X-Forwarded-For、X-Real-IP 或 Forwarded 請求頭的請求,管理面板和管理員 API 會跳過金鑰。在 Docker 預設的橋接網路上,發往已釋出埠的請求是從網橋的地址到達容器的,仍然需要金鑰。使用 --network host 時,來自主機本機的請求是迴環請求,不需要金鑰。位於同一網路名稱空間、且不新增轉發請求頭的反向代理,會讓它轉發的每個請求都看起來像本地請求。
  • 共享的除錯賬號。 同時設定了 debug_phase 時,每位訪問者都以同一個共享賬號 debug_user 登入,並被直接帶到該階段。這個賬號不是管理員,但所有訪問者共用該賬號的答案。

只要 debug 處於開啟狀態,伺服器在啟動時就會記錄一條警告。用 docker exec 在容器內執行的命令是迴環請求。因此在除錯模式下,docker exec potato curl -s http://127.0.0.1:7860/admin/health 不需要金鑰就會返回管理員健康報告。

健康檢查

GET /health 不需要認證,只報告伺服器是否已啟動。伺服器就緒後該路由返回 {"status": "ok"},伺服器仍在載入資料時返回狀態碼 503,負載均衡器或編排器可以把輪詢該路由作為就緒檢查。映像自帶的 HEALTHCHECK 每 30 秒輪詢一次 /health,併為大型任務留出 90 秒的啟動期,docker ps 會顯示檢查結果。GET /admin/health 報告任務名稱以及條目管理器和標註者管理器的狀態,這個路由需要在 X-API-Key 請求頭中提供管理員金鑰。

Docker Compose

Potato 倉庫不提供 Compose 檔案。生產部署給出了一個 Compose 檔案,它在 nginx 之後執行這個映像,項目資料夾掛載在 /app,會話金鑰和管理員金鑰從環境變數中讀取。

更新到新版本

更新 Potato 會替換容器,而項目資料夾連同所有標註和資料庫都原樣保留。切換之前,先拉取新版本,用新版本的校驗器檢查配置,併為項目資料庫做一次快照。請把 2.14.0 換成你要遷移到的版本:

bash
docker pull ghcr.io/davidjurgens/potato:2.14.0
docker run --rm -v "$PWD/myproject:/app" \
  ghcr.io/davidjurgens/potato:2.14.0 potato validate config.yaml --strict
docker exec potato sqlite3 /app/project.sqlite ".backup /tmp/snap.sqlite"
docker cp potato:/tmp/snap.sqlite ./project-before-update.sqlite

然後用新標籤的容器替換正在執行的容器,使用相同的掛載和相同的環境變數檔案:

bash
docker stop potato
docker rm potato
docker run -d --name potato -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  --env-file potato.env \
  --restart unless-stopped \
  ghcr.io/davidjurgens/potato:2.14.0

由於 potato.env 讓 POTATO_SECRET_KEY 保持不變,標註者在切換前後保持登入,管理員金鑰也不會改變。

故障排查

ERROR: config file not found: config.yaml。 項目沒有掛載到伺服器查詢的位置。錯誤資訊會列出 /app 的內容,方便你與自己的 -v 參數對照。列表為空通常意味著寫了 -v myproject:/app 這樣的裸名稱。Docker 會把裸名稱當作一個新的空卷,而不是你的資料夾。

ERROR: /app is not writable by uid 1000。 伺服器無法寫入項目資料夾。請把資料夾交給 uid 1000,或者用 --user 執行容器。Linux 上的檔案所有權一節給出了兩條命令。

output_annotation_dir ... is outside the project directory '/app'。 配置把標註輸出指向了掛載資料夾之外。請使用 annotation_output/ 這樣的相對路徑。

重啟後所有人都退出了登入。 沒有設定簽名金鑰,伺服器在啟動時選了一個新的隨機金鑰。請在環境變數檔案中設定 POTATO_SECRET_KEY,並把該檔案傳給每一條 docker run。

容器因一條提到 GUNICORN_WORKERS 的錯誤而退出。 工作程序數大於 1。請設定 GUNICORN_WORKERS=1 或刪除該變數,並改為提高 GUNICORN_THREADS。

管理員登入頁面拒絕所有金鑰。 配置中的 admin_api_key 優先於 POTATO_ADMIN_API_KEY,兩者又都優先於 admin_api_key.txt。請先檢查配置,再檢查 docker exec potato printenv POTATO_ADMIN_API_KEY。

延伸閱讀

實現細節見源文件。