
TL;DR:
在前幾天的旅程中,我從open-webui-ollama-qpkgv1.0.7 抽取出通用的 NAS 套件骨架(qpkg-template),並陸續為骨架補上了images.lock、CI 供應鏈簽署、權限管理與選用容器等機制。
今天,我把起點的專案「遷回」這套演進後的骨架。結果令人驚喜,原本 785 行 的服務腳本,業務層只剩下 140 行邏輯(縮減達 70%)。同時,我在遷移與實測過程中踩出了 Open WebUI 設定優先順序陷阱、Ollama 多節點 NFS 併發衝突,以及一個潛伏在範本核心的升級阻塞 Bug。
回顧整個系列的演進脈絡:
open-webui-ollama-qpkg v1.0.7 抽離出通用的「薄殼管理骨架」。images.lock、CI 產物 attestation、自動降級策略與動態容器開關(app_enabled_<id>)。算是一次簡單的「原型 > 框架抽象 > 業務回填」的完整工程循環。
在動手術前,我先依照這幾天建立的標準,體檢遷移前的 open-webui-ollama-qpkg v1.0.8(commit 7e92b57):
| 檢查維度 | 項目 | 狀態與說明 |
|---|---|---|
| 命名規範 | QPKG_NAME |
OpenWebUIOllama(早期即避開官方原名稱,升級無須改名) |
| 版本管理 | QPKG_VER |
1.0.8(管理腳本版本,與上游版本解耦) |
| 腳本規模 | 服務管理腳本 | openwebui-ollama.sh(POSIX sh),高達 785 行 |
| 容器架構 | 拓撲結構 | 雙容器(Open WebUI + Ollama),走私有橋接網路 |
| 映像檔來源 | Image Tags | ollama/ollama:latest、open-webui:main(浮動 Tag,重大隱患) |
| 硬體適應 | GPU 支援 | 具備自動偵測、失敗降級 CPU、開機延遲註冊 self-heal |
| 供應鏈安全 | CI / Release | 僅有舊版 build.yml,未鎖定 SHA,僅提供 .md5,無 SHA256 與 Attestation |
| 權限安全 | 目錄權限 | v1.0.8 已補上 chown -R 0:0 |
這個專案是所有案例中與範本血緣最近的,但它缺少了 Day 3 ~ Day 4 的所有安全供應鏈機制,映像檔也仍處於浮動風險中。
把 v1.0.7 的 785 行腳本與重構後的業務層(App 層)逐段對照,所有函式的流向如下:
[ 舊版 openwebui-ollama.sh (785 行) ]
├── Docker 環境偵測與非同步啟動 (120 行) ──────> 【核心 qpkg-core.sh】
├── 設定指紋比對與變更判定 (40 行) ─────────────> 【核心 qpkg-core.sh】
├── 下載狀態頁與背景 Pull (180 行) ──────────────> 【核心 qpkg-core.sh】
├── 容器碰撞/埠衝突/重建生命週期 (110 行) ───────> 【核心 qpkg-core.sh】(run_container 統一處理)
├── 輔助工具 (Port/Secret/TZ/JSON) (60 行) ─────> 【核心 qpkg-core.sh】
├── CLI 入口與診斷共通段落 (130 行) ─────────────> 【核心 qpkg-core.sh】
└── 專案專屬邏輯 (業務層) ───────────────────────> 【App 層 (僅 224 行)】
├── GPU 偵測與參數組合 (30 行)
├── Docker Run 指令組裝 (40 行)
├── GPU Self-heal 與降級hooks (25 行)
├── 預設變數與 Secret 宣告 (30 行)
└── Ollama / WebUI 專屬指紋 (15 行)
update --check、離線映像檔載入、選用容器開關與測試 Hook。在舊版程式碼中,GPU 處理邏輯雜散在 run_ollama 內超過 100 行的巢狀條件中,容易與「容器名稱衝突」、「連接埠佔用」等例外搞混。重構後,我將其收斂為三個職責單純的hooks:
# 1. 指紋hooks:僅追蹤使用者設定的模式,不收動態硬體偵測結果
app_fingerprint_ollama() {
printf '%s\n' "$OLLAMA_DATA_PATH" "$OLLAMA_PUBLISH_PORT" \
"$OLLAMA_NUM_PARALLEL" "$OLLAMA_MAX_LOADED_MODELS" \
"$OLLAMA_EXTRA_ARGS" "$GPU_MODE"
}
# 2. 自癒hooks (Self-Heal):修復開機時 NVIDIA Runtime 晚於 Docker 啟動的硬體丟失問題
app_needs_recreate_ollama() {
[ "$GPU_MODE" != "off" ] || return 1
detect_gpu || return 1
ollama_gpu_active && return 1
log "GPU is available but not attached to the existing Ollama container; recreating it with GPU pass-through (models are kept)." 4
return 0
}
# 3. 降級hooks (Fallback):GPU 啟動失敗時,優雅退回 CPU 模式
app_run_fallback_ollama() {
[ "$GPU_MODE" = "auto" ] || return 1
[ -n "$(gpu_args)" ] || return 1
log "Starting Ollama with GPU pass-through failed ($1); retrying CPU-only." 2
run_ollama_with ""
}
舊版曾發生過一個嚴重 Bug:當 docker run 因為名稱衝突失敗時,腳本誤判為「GPU 啟動失敗」,觸發重試路徑把原本正常的 GPU 容器強制重建成「純 CPU 容器」。
在範本核心中,名稱衝突與連接埠衝突會先在通用層被解決,傳入 app_run_fallback 的一定是真實的執行期異常。這項改動讓業務層不必再寫額外邏輯去防禦名稱撞車。
在我的實際場域中,主要推論運算交給機房內的 DGX Spark,NAS 上的 Ollama 僅作為本地備援(只有在 DGX 關機時才派上用場)。
透過 Day 6 實作的 app_enabled_<id>,管理員只要在 .conf 設定 ENABLE_OLLAMA=false:
app_enabled_ollama() {
[ "$ENABLE_OLLAMA" = "true" ]
}
核心就不會下載 Ollama 映像檔、不佔用資源起容器、狀態頁也不會報錯。
我原本打算透過環境變數分開傳遞:本地給 OLLAMA_BASE_URL,遠端給 OLLAMA_BASE_URLS。
但在翻閱 Open WebUI v0.11.3 的 config.py 後,發現了一個殘酷的事實:
只要
OLLAMA_BASE_URLS(複數)有設定任何值,Open WebUI 就會完全忽略OLLAMA_BASE_URL(單數)!
為了解決這個問題,App 層改為在本地 Ollama 啟用時,主動將其串接到清單的最末端:
WEBUI_OLLAMA_URL=""
[ "$ENABLE_OLLAMA" = "true" ] && WEBUI_OLLAMA_URL="http://$OLLAMA_CONTAINER_NAME:11434"
WEBUI_OLLAMA_URLS="$OLLAMA_BASE_URLS"
# 若同時有遠端與本地,將本地作為備援排在最後
if [ -n "$OLLAMA_BASE_URLS" ] && [ -n "$WEBUI_OLLAMA_URL" ]; then
WEBUI_OLLAMA_URLS="$OLLAMA_BASE_URLS;$WEBUI_OLLAMA_URL"
fi
實測發現更嚴苛的機制:
Open WebUI 在第一次啟動時,會把傳入的環境變數寫入內建 SQLite 資料庫;之後所有開機連線都以資料庫內容為準。
換句話說,如果容器已經初始化過,就算你修改 .conf 並 restart 換了環境變數,WebUI 依然會指向舊節點。此時必須進入「管理員設定 $\rightarrow$ Connections」手動刪除或更新。
既然本地 NAS 與遠端 DGX 都要跑 Ollama,能否將它們的 Storage 指向 NAS 上的同一個 NFS 資料夾(/root/.ollama),達到「一份模型、處處使用」?
我使用兩台容器對同一個 NFS 目錄同時執行 ollama pull smollm2:135m,進行壓力實測:
| 測試輪次 | 節點 A (DGX 1) | 節點 B (DGX 2) | 結果說明 |
|---|---|---|---|
| 第 1 輪 | 失敗 | 成功 | 節點 B 搶先完成,節點 A 噴錯終止 |
| 第 2 輪 | 失敗 | 失敗 | 兩者皆損毀 |
| 第 3 輪 | 失敗 | 失敗 | 兩者皆損毀 |
錯誤訊息均為:
Error: remove /root/.ollama/models/blobs/sha256-...-partial-0: no such file or directory
Ollama 在下載分片時會命名為 -partial-0、-partial-1。當兩個節點同時下載,彼此會互相刪除與覆寫對方的臨時檔。
更嚴重的是:一旦目錄留下 -partial 殘留檔,即使回到單一節點下載,也會永遠卡死報錯,必須手動刪除殘檔才能恢復。
:ro)掛載:掛載 :ro 時,Ollama 僅會跳出一行快取警告,推論與載入模型完全正常,且能避免意外寫入造成檔案毀損。.conf:安裝時若偵測到現有設定檔則不覆蓋;舊變數 WEBUI_PORT 自動相容對齊 WEB_PORT。WEBUI_SECRET_KEY 保持不變,使用者升級後登入 Session 不會被踢出。在測試 v1.0.8 升級至 v2.0.0 的過程時,我抓出了一個原本存在於 qpkg-template 核心的隱性缺陷:
# [舊核心邏輯]
if containers_all_exist || all_images_present; then
run_all
在舊思維(使用浮動 Tag)下,如果舊容器都存在,核心會認為「本機環境一切就緒」,直接呼叫 run_all。
但升級至 v2.0.0 後,因為設定指紋變更,核心必須拉取全新的鎖定 Digest。這導致 docker run 被迫在前景同步下載 5GB 的 Open WebUI 映像檔,導致 QNAP App Center 安裝進度條卡死數分鐘。
我在 qpkg-template v0.2.1 中重構了判斷函式:
container_startable() {
image_present "$(cvar "$1" IMAGE)" && return 0
CS_NAME=$(cvar "$1" CONTAINER_NAME)
container_exists "$CS_NAME" || return 1
! config_changed "$CS_NAME" "$(container_fingerprint "$1")"
}
只有在「映像檔已在本機」或「容器存在且不需重建」時,才允許直接在前景呼叫 docker start;否則一律轉入背景下載,並啟動臨時 Web 狀態頁對外告知進度。
修復後,在實體 NAS 上進行原地升級,日誌完整還原了非同步交接過程:
01:15:07 [INFO] App Center 停止舊版 v1.0.8(保留容器與資料)
01:15:27 [INFO] v2.0.0 啟動,偵測到映像檔不在本機,啟動 Web 狀態頁監聽 3000 埠
01:15:31 [INFO] Ollama Digest 與本地相符,略過下載
01:20:57 [INFO] Open WebUI 映像檔背景拉取完成,開始重建 Ollama
01:20:59 [INFO] Open WebUI 重建完成
01:21:32 [INFO] Open WebUI /health 回傳 {"status":true},狀態頁關閉,無縫交接!
start 指令在 20 秒內立即回傳給系統,App Center 完全不卡頓,後續 5 分鐘的下載工作全數由背景與狀態頁妥善接管。
為了確保軟體生命週期的穩固,在 CI 與實體機器上建立了三層驗證:
| 驗證層次 | 測試環境 | 測試內容與範疇 |
|---|---|---|
| 單元與生命週期測試 | GitHub Actions / 本地 | 84 項全自動測試(以輕量 traefik/whoami 模擬完整生命週期、GPU 回退、狀態頁交接、升級模擬) |
| 真實映像檔測試 | DGX Spark (arm64) | 載入真映像檔,驗證 /health 28 秒啟動交接、NVIDIA 容器透通、多 URL 備援逾時 |
| 實體 NAS 升級測試 | QNAP NAS (x86_64) | 驗證從 v1.0.8 無損升級、GPU 延遲掛載 Self-Heal 觸發 |
CI 踩坑小提醒:
GitHub Actions 的actions/checkout預設只抓取最新的一個 Commit(fetch-depth: 1)。在執行升級情境測試時,腳本需要執行git show v1.0.8:...來比對舊版檔案,若未在 Workflow 加上fetch-depth: 0,測試將會靜默略過升級檢查。
走到今天,完成了這趟旅程的第一個里程碑,將抽象出來的框架,成功套回原始專案並落地驗證。
| 天數 | 骨架(qpkg-template)進展 | 落地專案狀態 |
|---|---|---|
| Day 2 | 抽離出生命週期、狀態頁與設定指紋機制 | 確立專案骨架 |
| Day 3 | 導入 images.lock、Digest 釘選、update --check |
Jellyfin 需求盤點 |
| Day 4 | 建立 GitHub Actions 供應鏈簽署、產物 Attestation | 確立 CI 標準 |
| Day 5 | 抽象化 GPU 三大hooks(指紋、自癒、降級) | Jellyfin / Roon 遷移規劃 |
| Day 6 | 實作動態容器開關 app_enabled_<id> 與目錄權限收斂 |
ChangeDetection / Homepage 修補 |
| Day 7 | 自建映像檔四種版本來源與 GPU 透通規範 | ComfyUI 遷移規劃 |
| Day 8 | 修復 v0.2.1 換版啟動阻塞 Bug | Open WebUI + Ollama v2.0.0 正式發佈並通過升級驗證 |
如果你想在自己的機器上下載、驗證並測試這個版本,可以直接執行以下指令:
# 1. 下載 release 產物與簽章檔案
BASE_URL=https://github.com/ivanusto/open-webui-ollama-qpkg/releases/download/v2.0.0
for file in OpenWebUIOllama_2.0.0_x86_64.qpkg SHA256SUMS images.lock NOTICE.md LICENSE; do
curl -sLO "${BASE_URL}/${file}"
done
# 2. 驗證雜湊值與 GitHub 官方產物 Attestation
sha256sum -c SHA256SUMS
gh attestation verify OpenWebUIOllama_2.0.0_x86_64.qpkg \
--repo ivanusto/open-webui-ollama-qpkg --source-ref refs/tags/v2.0.0
# 3. 安裝至 NAS 後,檢視 GPU 狀態與自癒診斷
S=/etc/init.d/openwebui-ollama.sh
sudo $S diag | sed -n '/--- app/,/--- last pull/p'
sudo $S update --check
第一階段「單機套件與通用骨架建構」到今天告一段落。
從 Day 9 開始,我將邁入第二階段,AI 算力叢集與多節點架構。我將帶入 DGX 伺服器,探討當單一 NAS 無法滿足大型語言模型時,如何進行硬體容量規劃與跨節點共用儲存架構。