
Day 1 說第一類維運問題是服務沒有被封裝。用 docker compose 手動拉起來的服務,升級靠記憶,設定散在各處,換一台機器就要重來一次。今天從封裝的第一步開始,談 QNAP Container Station 上的薄殼架構。
所謂薄殼,指的是 QPKG 套件本身不包含任何 Docker image、二進位檔或模型,只放三種東西。第一是 QDK 的套件描述與安裝腳本,第二是生命週期腳本,第三是一個首次啟動時顯示進度的狀態頁。整個套件解開來不到 1 MB,image 由 Container Station 在安裝後於背景下載。
今天的參考實作是 open-webui-ollama-qpkg 的 v1.0.7,整篇文章的檔案連結都釘在該版本的 commit 51881ab 上。這個專案的作法沿用自更早的 roon-qpkg 專案,架構則是參考 QNAP 官方的 qnap-dev/containerized-qpkg 進行實作的。Day 3 之後會把這套骨架抽出來成為獨立的 qpkg-template,讓後面幾個案例共用。

先講為什麼不把 image 打進 QPKG。
| 項目 | 厚殼(image 隨套件打包) | 薄殼(image 由 Container Station 下載) |
|---|---|---|
| 套件大小 | 數百 MB 到數 GB | 小於 1 MB |
| 上游釋出新版 | 重新打包、重新發布、使用者重新下載整包 | 改設定檔中的 image 參考,或執行 update 子命令 |
| 安裝時間 | 受套件大小影響,App Center 安裝過程會卡住 | 數秒完成,下載在背景進行 |
| 版本可追溯性 | 打包時的 image 固定,但沒有人記得是哪個 digest | 設定檔明確記錄 image 參考,Day 3 會補上 digest 鎖定 |
| 離線環境 | 可離線安裝 | 需要能連到 registry |
薄殼的代價是最後一列。這個場域的 NAS 能連外,所以接受這個限制。若要在隔離網段部署,Day 3 談供應鏈時會提到私有 registry 與 image 匯出的作法。
厚殼還有一個更根本的問題。QPKG 的版本號與上游 image 的版本號綁在一起,每一次上游更新都變成一次打包工作,維護者很快就會放棄跟進。薄殼把兩件事拆開,QPKG 版本只代表管理腳本的版本,image 版本由設定檔決定。
v1.0.7 的檔案結構如下,不含 CI 與 README。
open-webui-ollama-qpkg/
├── qpkg.cfg # QDK 套件描述(43 行)
├── package_routines # 安裝與移除鉤子(75 行)
├── shared/
│ ├── openwebui-ollama.sh # 生命週期腳本(785 行)
│ ├── openwebui-ollama.conf.default # 設定檔範本
│ └── web/index.html # 狀態頁(380 行)
├── icons/ # App Center 圖示三種尺寸
├── x86_64/.gitkeep # 架構專屬目錄,本套件為空
├── Dockerfile # QDK 打包用的 builder image
└── Makefile # make 即產出 .qpkg
四個核心檔案合計約 1,300 行,其中生命週期腳本佔六成。這個比例反映薄殼架構的重點,套件的價值不在打包的內容,而在啟動、停止、升級與故障時的行為。
qpkg.cfg 是 QDK 的套件描述檔,幾個欄位值得說明。
QPKG_NAME="OpenWebUIOllama"
QPKG_DISPLAY_NAME="Open WebUI + Ollama"
QPKG_VER="1.0.7"
QPKG_RC_NUM="150"
QPKG_SERVICE_PROGRAM="openwebui-ollama.sh"
QPKG_REQUIRE="container-station >= 3.0"
QTS_MINI_VERSION="5.0.0"
QPKG_WEBUI="/"
QPKG_WEB_PORT="3000"
QDK_DATA_DIR_SHARED="shared"
QPKG_NAME 刻意不用 OpenWebUI 或 Ollama。App Center 以內部名稱判斷是否為同一個 App,若 QNAP 商店日後出現同名套件,會被視為同一個 App 而強制覆蓋更新。這是 roon-qpkg 專案踩過的問題,原始碼註解裡有記錄。
QPKG_REQUIRE 宣告相依 Container Station 3.0 以上,App Center 會在安裝前檢查。QPKG_SERVICE_PROGRAM 指定的腳本會被 QDK 安裝到 /etc/init.d/,QTS 開機與 App Center 的啟用停用都會呼叫它。QPKG_WEBUI 與 QPKG_WEB_PORT 決定 App Center 圖示上「開啟」按鈕的連結,這裡直接指向 Open WebUI 本身的埠,狀態頁在首次安裝時會暫時佔用同一個埠。
package_routines 是 QDK 定義的安裝 hook,qbuild 會把它嵌進安裝腳本。薄殼架構下,多數 hook 是空的,真正有內容的只有三個。
pkg_check_requirement 在 QPKG_REQUIRE 之外再確認一次 Container Station 的安裝路徑存在,失敗時給出明確的錯誤訊息,而非讓後面的腳本在找不到 docker CLI 時才莫名其妙地失敗。
pkg_check_requirement(){
CS_DIR=$(/sbin/getcfg container-station Install_Path -f /etc/config/qpkg.conf 2>/dev/null)
if [ -z "$CS_DIR" ]; then
err_log "Container Station is required but not installed. Please install Container Station from App Center first."
fi
}
pkg_post_install 做四件事。設定檔只在不存在時才從範本複製,重新安裝或升級時保留使用者改過的值。檢查設定的網頁埠是否已被佔用,只警告不中止。把設定檔記錄的埠回寫 App Center 的連結。最後呼叫服務腳本的 bgpull 在背景下載 image,讓安裝程序立即返回。
pkg_post_install(){
if [ ! -f "${SYS_QPKG_DIR}/openwebui-ollama.conf" ]; then
cp "${SYS_QPKG_DIR}/openwebui-ollama.conf.default" "${SYS_QPKG_DIR}/openwebui-ollama.conf"
fi
chmod +x "${SYS_QPKG_DIR}/openwebui-ollama.sh"
mkdir -p "${SYS_QPKG_DIR}/logs"
WEBUI_PORT=$(. "${SYS_QPKG_DIR}/openwebui-ollama.conf" >/dev/null 2>&1; echo "${WEBUI_PORT:-3000}")
if netstat -lnt 2>/dev/null | grep -q ":${WEBUI_PORT} "; then
/sbin/write_log "[OpenWebUIOllama] Port ${WEBUI_PORT} is already in use. ..." 2
fi
"${SYS_QPKG_DIR}/openwebui-ollama.sh" bgpull
/sbin/write_log "[OpenWebUIOllama] Installed. ... images are being downloaded ... in the background" 4
}
PKG_PRE_REMOVE 呼叫服務腳本的 remove 子命令,只移除容器與私有網路。模型與對話資料留在磁碟上,移除後透過 /sbin/write_log 寫一筆事件紀錄告訴使用者資料還在、要手動刪。這個決定跟 Day 17 的備份演練有關,套件移除不等於資料銷毀,兩者要分開處理。
openwebui-ollama.sh 除了 QTS 要求的 start、stop、restart、status,另外定義了 pull、bgpull、update、remove、diag 五個子命令,以及兩個只給背景工作用的內部命令 _bg_start 與 _bg_pull。
785 行裡有一半在處理 Container Station 與 QTS 開機順序的邊界情況。以下挑四個設計說明,每一個都對應一次實際踩到的問題。
QTS 沒有系統層級的 docker,CLI 由 Container Station 提供。腳本從 qpkg.conf 讀出 Container Station 的安裝路徑,依序嘗試 bin/docker、/usr/local/bin/docker、system-docker。優先用一般的 docker 而非 system-docker,這樣建立的容器才會出現在 Container Station 的 UI 裡,使用者能用圖形介面看 log 或重啟。
docker info 回應成功不代表 daemon 可用。實測發現 Container Station 剛啟動時,info 已經有回應但 inspect 與 images 仍回空,腳本會誤判既有容器不存在而走進重新下載的路徑。所以 docker_ready 同時檢查 info 與 ps -q,wait_docker_ready 要求相隔 10 秒的兩次連續成功才算就緒。
docker_ready() {
[ -n "$DOCKER" ] || DOCKER=$(find_docker)
[ -n "$DOCKER" ] || return 1
"$DOCKER" info >/dev/null 2>&1 && "$DOCKER" ps -q >/dev/null 2>&1
}
QPKG_RC_NUM=150 決定開機時的啟動順序,但無法保證 Container Station 在這個腳本執行時已經就緒。start 子命令的做法是先試 docker_ready,可用就直接啟動,不可用就寫入 waiting-for-container-station 狀態,然後派生一個背景工作 _bg_start 去等,主程序立即返回。
背景工作用 setsid 派生而非 nohup。App Center 在安裝或啟動腳本結束後會回收整個 process group,nohup 的子程序會跟著被回收,工作看起來有派生但從未真正執行。setsid 讓它成為獨立的 session,才能活過這個回收。
spawn_detached() {
SELF="$QPKG_ROOT/openwebui-ollama.sh"
if command -v setsid >/dev/null 2>&1; then
setsid "$SELF" "$1" </dev/null >/dev/null 2>&1 &
else
nohup "$SELF" "$1" </dev/null >/dev/null 2>&1 &
fi
}
_bg_start 等到 daemon 就緒後還有一段 settle 迴圈。腳本用 .images-ready 標記檔記錄這個 App 曾經成功啟動過,若標記存在但容器與 image 都查不到,唯一合理的解釋是 daemon 的物件庫還在載入,所以繼續等而非重新下載。實測這個載入期在開機後可以超過兩分鐘。
docker start 會原封不動地重用容器建立時的參數,設定檔改了埠或資料路徑,既有容器永遠不會知道。run_ollama 與 run_webui 把所有會出現在 docker run 命令列上的參數做一次雜湊,記錄在 .conf-<容器名> 檔案裡。啟動時比對指紋,不同就刪掉容器重建,相同就直接 docker start。
ollama_fingerprint() {
fingerprint "$OLLAMA_IMAGE" "$OLLAMA_DATA_PATH" "$OLLAMA_PUBLISH_PORT" \
"$OLLAMA_NUM_PARALLEL" "$OLLAMA_MAX_LOADED_MODELS" "$OLLAMA_EXTRA_ARGS" \
"$GPU_MODE" "$NETWORK_NAME" "$TZ"
}
指紋刻意排除偵測到的 GPU 狀態。NVIDIA runtime 在開機時可能晚於 Container Station 註冊,若把 GPU 狀態算進指紋,每次開機順序不同就會觸發一次不必要的重建。GPU 的補救另外走一條路徑,容器停著且偵測到 GPU 可用但未掛載時才重建。這個部分 Day 5 談 GPU 自動偵測時再展開。
另一個原則是永遠不對既有容器做 docker run。這會得到名稱衝突錯誤,而在 GPU 重試邏輯裡,一個名稱衝突會被誤讀成 GPU 啟動失敗,導致一個原本有 GPU 的容器被重建成純 CPU。腳本先 inspect 確認存在與否,存在就走 start 路徑。
diag 子命令一次印出 docker CLI 路徑與版本、GPU runtime 偵測結果、registry 的 DNS 解析、image 與容器清單、私有網路設定、資料路徑與最後 20 行下載 log。使用者回報問題時,一份 diag 輸出就涵蓋九成以上的排查所需資訊。這是每一個薄殼套件都應該有的子命令,qpkg-template 會把它列為必要項目。
薄殼架構的使用者體驗問題在首次安裝。套件裝完了,image 還要下載幾分鐘到幾十分鐘,這段期間點 App Center 的「開啟」會得到連線被拒。
解法是在下載期間用一個一次性的 busybox httpd 容器佔住 WEBUI_PORT,提供 shared/web/index.html 這個靜態頁。頁面每 5 秒讀取兩個檔案,status.json 是腳本在每次狀態轉換時寫出的結構化狀態,pull-progress.txt 是 _bg_pull 每 5 秒截取的最後 15 行下載 log。
status.json 的狀態值涵蓋整個生命週期。
| 狀態 | 意義 |
|---|---|
waiting-for-container-station |
開機中,等待 Container Station 就緒 |
downloading-image |
image 下載中,頁面顯示進度 |
pull-failed |
三次重試後下載仍失敗,提示執行 diag |
starting |
容器建立與啟動中 |
running |
兩個容器都在執行 |
stopped |
使用者停用 |
error |
容器啟動失敗,指向 log 檔 |
no-container-engine |
找不到 docker CLI 或等待逾時 |
下載完成後真正的 Open WebUI 容器起來之前,狀態頁容器會被移除。這裡有一個時間差,Open WebUI 首次啟動會跑資料庫遷移,可能要幾分鐘,這段期間埠是無人回應的。若頁面在此時 reload,瀏覽器會停在錯誤頁且不再重試。v1.0.7 的修正是頁面在讀不到 status.json 時改為輪詢 /health,收到 Open WebUI 的健康回應才 reload。同一個網址從狀態頁無縫換成應用程式本身,使用者不需要做任何事。
狀態頁容器用 --net host 而非 -p 埠對應。原因是 dockerd 管理的埠綁定在 daemon 不穩定時可能外洩,容器被強制移除後埠仍被 docker-proxy 佔住,直到 daemon 重啟為止,真正的 Open WebUI 容器就起不來。host 網路模式下 socket 隨 httpd 程序結束而釋放。stop_landing 另外會用命令列簽章找出 daemon 已經不認得的殘留 httpd 程序,以 SIGKILL 清掉。
薄殼架構回答了 Day 1 結尾的問題。image 參考寫在設定檔,預設值是 ollama/ollama:latest 與 ghcr.io/open-webui/open-webui:main。要跟上游,執行 update 子命令即可,它會先 docker pull 兩個 image,成功後才刪除舊容器重建,失敗則保留現有容器不動。
do_update() {
pull_images || { log "Image update failed; keeping current containers." 1; return 1; }
"$DOCKER" rm -f "$OLLAMA_CONTAINER_NAME" >/dev/null 2>&1
"$DOCKER" rm -f "$WEBUI_CONTAINER_NAME" >/dev/null 2>&1
ensure_network
if run_ollama && run_webui; then
write_status "running"
else
write_status "error"
return 1
fi
}
QPKG 本身只在管理腳本有變動時才需要新版本。v1.0.3 到 v1.0.7 五個版本的 release notes 全部在修腳本的邊界情況,沒有任何一版是為了跟上游 image 而發。
不過用 latest 與 main 這種浮動 tag 有明顯的風險。Day 1 提到的「容器升級後 GPU 突然抓不到,回頭才發現 image 的 tag 被上游換過」就是這個場域發生過的事。薄殼架構讓升級變容易,也讓不可控的升級變容易。Day 3 談版本鎖定與供應鏈,用 image digest 取代浮動 tag,讓 update 只在有人明確變更設定檔時才真的換版本。
把 open-webui-ollama-qpkg 的結構攤開,可以分成兩層。
可以抽象成範本的部分,包括 qpkg.cfg 的欄位規範與命名注意事項、package_routines 的三個鉤子邏輯、服務腳本裡的 docker 探測與等待、背景派生、冪等啟動與指紋比對、狀態頁的 JSON 狀態機與輪詢邏輯、diag 子命令、以及 QDK 的 Dockerfile 與 Makefile。這些在 Roon、ComfyUI、Jellyfin 幾個專案裡幾乎逐字相同。
案例專屬的部分,只有容器數量與名稱、image 參考、docker run 的參數、GPU 或硬體轉碼的偵測邏輯、以及狀態頁上要顯示的欄位。
qpkg-template 的目標是把第一層變成可以直接複製的骨架,第二層集中在一個設定區塊與少數幾個函式裡。Day 3 與 Day 4 會在這個骨架上加版本鎖定與 CI,Day 5 到 Day 8 的四個案例則展示第二層怎麼填。
打包環境放在 Docker 裡,不需要在本機安裝 QDK。
git clone https://github.com/ivanusto/open-webui-ollama-qpkg
cd open-webui-ollama-qpkg
git checkout v1.0.7
make # 建 builder image 並執行 qbuild,產出 build/OpenWebUIOllama_1.0.7_x86_64.qpkg
Dockerfile 基於 Ubuntu 22.04 安裝 qnap-dev/QDK。要注意 QDK 的安裝腳本會編譯 qpkg_encrypt,qbuild 用它加密套件內容,缺少 gcc 會產出未加密的 .qpkg,App Center 安裝時會回報檔案格式錯誤。
安裝到 NAS 的步驟。App Center 右上角手動安裝,選擇 .qpkg 檔。套件未經 QNAP 簽章,若被拒絕,到 App Center 設定的一般頁籤允許安裝未簽署的應用程式。安裝完成後點圖示即可看到狀態頁。
要驗證薄殼的行為,可以在 NAS 上 SSH 進去執行。
/etc/init.d/openwebui-ollama.sh diag
cat /share/CACHEDEV1_DATA/.qpkg/OpenWebUIOllama/web/status.json
路徑依 NAS 的預設磁碟區而異。
Day 3 談版本鎖定與供應鏈。今天的設定檔用的是 latest 與 main,明天會換成 image digest,並且在 release 附上 SHA256SUMS 與授權檔,讓每一個 .qpkg 都能回答「裡面的 image 是哪一個、誰打包的、能不能驗證」。
系列文章與程式碼索引:onprem-ops-30days
本日程式碼:open-webui-ollama-qpkg @ v1.0.7
參考資料