iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
IT Operation

地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理系列 第 8

Day 8|案例實戰:Open WebUI + Ollama 重構記,把專案回到通用框架會省下什麼呢?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260922/20141816WYUZXaEshu.png

TL;DR
在前幾天的旅程中,我從 open-webui-ollama-qpkg v1.0.7 抽取出通用的 NAS 套件骨架(qpkg-template),並陸續為骨架補上了 images.lock、CI 供應鏈簽署、權限管理與選用容器等機制。
今天,我把起點的專案「遷回」這套演進後的骨架。結果令人驚喜,原本 785 行 的服務腳本,業務層只剩下 140 行邏輯(縮減達 70%)。同時,我在遷移與實測過程中踩出了 Open WebUI 設定優先順序陷阱Ollama 多節點 NFS 併發衝突,以及一個潛伏在範本核心的升級阻塞 Bug


前言:從它抽出骨架,再把它遷回骨架

回顧整個系列的演進脈絡:

  • Day 2:我從 open-webui-ollama-qpkg v1.0.7 抽離出通用的「薄殼管理骨架」。
  • Day 3 ~ Day 7:骨架逐漸成熟,具備了鎖定 digest 的 images.lock、CI 產物 attestation、自動降級策略與動態容器開關(app_enabled_<id>)。
  • Day 8(今天):我要驗證這套抽象化架構是否真正通用。驗證方式很簡單:把起點專案遷回新範本,發佈 v2.0.0

算是一次簡單的「原型 > 框架抽象 > 業務回填」的完整工程循環。


體檢報告:遷移前的舊版本盤點

在動手術前,我先依照這幾天建立的標準,體檢遷移前的 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:latestopen-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 的所有安全供應鏈機制,映像檔也仍處於浮動風險中。


一、程式碼大瘦身:785 行到 140 行的職責分離

把 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 行)

重構成果統計

  • App 層程式碼:總計 224 行(扣除 84 行註解與排版空行,真實業務邏輯僅 140 行)。
  • 共通核心:975 行。多出來的行數吸收了 Digest 鎖定校驗、update --check、離線映像檔載入、選用容器開關與測試 Hook。
  • 維護效益:專案自體程式碼減少了 70%,卻免費繼承了範本在過去五天迭代累積的所有安全與生命週期防護。

二、GPU 適應性:三個獨立 hooks

在舊版程式碼中,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 的一定是真實的執行期異常。這項改動讓業務層不必再寫額外邏輯去防禦名稱撞車。


三、解決問題實錄 1:Open WebUI 的連線備援與設定陷阱

在我的實際場域中,主要推論運算交給機房內的 DGX Spark,NAS 上的 Ollama 僅作為本地備援(只有在 DGX 關機時才派上用場)。

1. 動態關閉本地 Ollama

透過 Day 6 實作的 app_enabled_<id>,管理員只要在 .conf 設定 ENABLE_OLLAMA=false

app_enabled_ollama() {
    [ "$ENABLE_OLLAMA" = "true" ]
}

核心就不會下載 Ollama 映像檔、不佔用資源起容器、狀態頁也不會報錯。

2. 多節點 URL 拼接的順序陷阱

我原本打算透過環境變數分開傳遞:本地給 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

3. Open WebUI 的資料庫持久化陷阱

實測發現更嚴苛的機制:
Open WebUI 在第一次啟動時,會把傳入的環境變數寫入內建 SQLite 資料庫;之後所有開機連線都以資料庫內容為準
換句話說,如果容器已經初始化過,就算你修改 .confrestart 換了環境變數,WebUI 依然會指向舊節點。此時必須進入「管理員設定 $\rightarrow$ Connections」手動刪除或更新。


四、解決問題實錄 2:Ollama 模型庫共用 NFS 的併發寫入慘劇

既然本地 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 殘留檔,即使回到單一節點下載,也會永遠卡死報錯,必須手動刪除殘檔才能恢復

架構結論:單寫多讀(Single-Writer, Multi-Reader)

  1. 禁止多節點同時寫入:共用模型目錄只允許單一指定節點具備寫入權限。
  2. 其餘節點一律唯讀(:ro)掛載:掛載 :ro 時,Ollama 僅會跳出一行快取警告,推論與載入模型完全正常,且能避免意外寫入造成檔案毀損。

五、升級路徑與隱藏在範本核心的「啟動阻塞」Bug

升級無感性設計

  • 保留使用者的 .conf:安裝時若偵測到現有設定檔則不覆蓋;舊變數 WEBUI_PORT 自動相容對齊 WEB_PORT
  • 保留 Secret KeyWEBUI_SECRET_KEY 保持不變,使用者升級後登入 Session 不會被踢出。
  • 平滑遷移:升級時核心自動比對出「指紋變更」,以鎖定的 Digest 重新建置容器,既有模型與掛載資料完全保留。

抓出範本核心的致命缺陷(v0.2.1 修正)

在測試 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 實機升級時序驗證

修復後,在實體 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,測試將會靜默略過升級檢查。


七、第一階段總結(Day 1 ~ Day 8)

走到今天,完成了這趟旅程的第一個里程碑,將抽象出來的框架,成功套回原始專案並落地驗證

天數 骨架(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 無法滿足大型語言模型時,如何進行硬體容量規劃與跨節點共用儲存架構。


相關專案與參考資源


上一篇
Day 7|在地自建 ComfyUI 的生存指南:自建 Image 鎖定、GPU 直通與三層目錄架構
下一篇
Day 9|容量規劃:用模型與硬體搭配矩陣決定地端節點與儲存規格
系列文
地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言