iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
IT Operation

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

Day 2|Container Station 薄殼架構:QPKG 骨架、生命週期腳本與狀態頁

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260916/20141816oRu9pknrAU.png

前言:套件裡不放主要程式,只放「怎麼管」的框架處理原則

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,讓後面幾個案例共用。

https://ithelp.ithome.com.tw/upload/images/20260916/20141816A4TN5VOZ52.jpg

厚殼與薄殼的差別

先講為什麼不把 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 版本由設定檔決定。

QPKG 骨架

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

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 刻意不用 OpenWebUIOllama。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_WEBUIQPKG_WEB_PORT 決定 App Center 圖示上「開啟」按鈕的連結,這裡直接指向 Open WebUI 本身的埠,狀態頁在首次安裝時會暫時佔用同一個埠。

生命週期一:安裝與移除

package_routines 是 QDK 定義的安裝 hook,qbuild 會把它嵌進安裝腳本。薄殼架構下,多數 hook 是空的,真正有內容的只有三個。

pkg_check_requirementQPKG_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 要求的 startstoprestartstatus,另外定義了 pullbgpullupdateremovediag 五個子命令,以及兩個只給背景工作用的內部命令 _bg_start_bg_pull

785 行裡有一半在處理 Container Station 與 QTS 開機順序的邊界情況。以下挑四個設計說明,每一個都對應一次實際踩到的問題。

找到 Container Station 的 docker,並確認它真的醒了

QTS 沒有系統層級的 docker,CLI 由 Container Station 提供。腳本從 qpkg.conf 讀出 Container Station 的安裝路徑,依序嘗試 bin/docker/usr/local/bin/dockersystem-docker。優先用一般的 docker 而非 system-docker,這樣建立的容器才會出現在 Container Station 的 UI 裡,使用者能用圖形介面看 log 或重啟。

docker info 回應成功不代表 daemon 可用。實測發現 Container Station 剛啟動時,info 已經有回應但 inspectimages 仍回空,腳本會誤判既有容器不存在而走進重新下載的路徑。所以 docker_ready 同時檢查 infops -qwait_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
}

永遠不阻塞 QTS 開機

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_ollamarun_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:latestghcr.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 而發。

不過用 latestmain 這種浮動 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 談版本鎖定與供應鏈。今天的設定檔用的是 latestmain,明天會換成 image digest,並且在 release 附上 SHA256SUMS 與授權檔,讓每一個 .qpkg 都能回答「裡面的 image 是哪一個、誰打包的、能不能驗證」。


系列文章與程式碼索引:onprem-ops-30days

本日程式碼:open-webui-ollama-qpkg @ v1.0.7

參考資料


上一篇
Day 1|系列規劃與場域總覽:從跑得起來到可長期維運,先談為什麼要封裝
下一篇
Day 3|版本鎖定與供應鏈:image digest、SHA256SUMS 與授權檔
系列文
地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言