iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
IT Operation

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

Day 3|版本鎖定與供應鏈:image digest、SHA256SUMS 與授權檔

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260917/20141816o18qPKJV3A.png

前言:薄殼讓升級變容易,也讓不可控的升級變容易

Day 2 把套件做薄,image 由 Container Station 下載,QPKG 本身只剩管理腳本與狀態頁。設定檔裡的 image 參考是 ollama/ollama:latestghcr.io/open-webui/open-webui:main,執行 update 就跟上游。

這也是 Day 1 提到的那件事發生的原因。容器升級後 GPU 突然抓不到,回頭才發現 image 的 tag 被上游換過。latest 這個 tag 在上游每次發版時都會指向新的內容,同一個字串在不同時間 docker pull 下來的東西並不相同。設定檔記錄的是一個名字,記錄不了名字背後的內容。

今天繼續把這些完善並最佳化往下走。第一,用 image digest 取代浮動 tag,讓套件記錄的是內容而非名字。第二,release 附上 SHA256SUMS 與建置來源證明,讓使用者能驗證下載的 .qpkg 與 CI 從這份原始碼建出來的是同一份。第三,把授權檔補齊,包括套件本身的授權、上游的授權、以及一份說明來源與商標的 NOTICE。

這三件事的落地處是 qpkg-template。Day 2 說要把 open-webui-ollama-qpkg 的骨架抽出來,今天它以 v0.1.0 公開,文中的檔案連結釘在 v0.1.0 的 commit db76427。今天補上的 NOTICE.md 與一項隔離網段的修正在 v0.1.1(commit cd68c4b),相關連結另外標明。範本內建的示範 App 是 traefik/whoami,一個幾 MB 的無狀態 HTTP 服務,用來證明骨架打包得出來、裝得起來。

四個既有專案的現況

先盤點手上四個 QPKG 專案在這三件事上的現況,全部依 repo 內容記錄,日期為 2026-09-17。

專案 image 參考 release 校驗碼 套件授權 上游來源說明
open-webui-ollama-qpkg v1.0.7 ollama/ollama:latestghcr.io/open-webui/open-webui:main qbuild 產出的 .md5 Apache-2.0
roon-qpkg v1.2.3 ghcr.io/roonlabs/roonserver:latest qbuild 產出的 .md5 LICENSE 檔為 Apache-2.0,qpkg.cfg 寫 MIT README 內一段文字
Jellyfin-QPKG v1.2.1 jellyfin/jellyfin:latest CI 產出 SHA256SUMS GPL-3.0 NOTICE.md
qnap-comfyui-qpkg v0.35.1-1 在 NAS 上自建 image,COMFY_REF=v0.35.1,torch 版本寫死 qbuild 產出的 .md5 Apache-2.0
qpkg-template v0.1.0 images.lock 鎖定 digest CI 產出 SHA256SUMS 與 attestation Apache-2.0 v0.1.1 補上 NOTICE.md

四個既有專案裡沒有一個用 digest 鎖定 image。ComfyUI 是唯一把上游版本寫進 QPKG 版本號的專案,0.35.1-1 代表 ComfyUI v0.35.1 加上套件第一次修訂,這個命名方式值得保留,但它鎖的是 git ref 而非 image 內容,基底 image python:3.12-slim-bookworm 仍然是浮動的。

校驗碼方面,qbuild 預設產出 MD5。MD5 早已不適合作為完整性驗證,IETF 的 RFC 6151 明確指出「MD5 is no longer acceptable where collision resistance is required such as digital signatures」。校驗碼要防的正是有人換掉檔案卻讓雜湊值不變,需要的就是抗碰撞性。Jellyfin-QPKG 是唯一在 CI 裡另外產出 SHA256SUMS 的既有專案,這個做法進了範本並往前多走一步。

授權方面,roon-qpkg 的 QPKG_LICENSE 欄位與 LICENSE 檔不一致,App Center 顯示的是 qpkg.cfg 的值,要修。Jellyfin-QPKG 是 fork 自沒有授權檔的上游專案,NOTICE.md 說明了這個狀況與商標歸屬,這份檔案的格式今天抽成範本。

一、image digest 鎖定

tag 與 digest 的差別

Docker image 的 tag 是可變的指標,digest 是內容的 SHA-256 雜湊。同一個 digest 在任何 registry、任何時間拉下來的內容都一樣,因為 digest 由內容算出來,拉取時會用它驗證收到的內容,不符就失敗。

參考格式允許同時寫 tag 與 digest。

traefik/whoami:v1.12.0@sha256:c4717a8d1f0134a7444e24f881160e033991f23027c6c5a9a3f8fd22e70d1d44

Docker 解析這個參考時以 digest 為準,tag 只保留給人看。多架構 image 有兩層 digest,tag 指向 manifest list,裡面列出各平台的 manifest 與各自的 digest。範本鎖定的是 manifest list 的 digest,Docker 會依主機平台自動選擇,x86_64 NAS 與 arm64 的 DGX Spark 用同一個字串就能拉到各自平台的 image。

為什麼是獨立的 images.lock,而非寫在設定檔

Day 2 的設計把 image 參考放在 <app>.conf.default,安裝時複製成使用者的 .conf,重新安裝或升級時保留使用者版本。這個保留機制對使用者調整過的埠、路徑是正確的,對 image 版本卻是錯的。套件升級到新版,帶進來的 image 鎖定值應該跟著換,但 .conf 被保留下來,舊的 image 參考就跟著留下來。

範本把兩件事拆開。shared/images.lock 隨套件出貨,每次升級整個換掉,代表「這一版套件驗證過的 image 版本」。.conf 仍然可以覆寫 image,覆寫值優先於 lock,代表「使用者自己決定的版本」。兩者的責任歸屬不同,紀錄的位置也不同。

APP_IMAGE=traefik/whoami:v1.12.0@sha256:c4717a8d1f0134a7444e24f881160e033991f23027c6c5a9a3f8fd22e70d1d44
LANDING_IMAGE=busybox:1.36.1@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662

第二行值得注意。Day 2 的狀態頁容器用 busybox:stable,也是浮動 tag。狀態頁容器只跑幾分鐘,但它跑在 host 網路模式下,任何一個進到 NAS 的 image 都應該被鎖定,範本把它一起鎖了。

lock 檔的讀取方式是用 sed 解析,不是 source。設定檔用 source 是既有做法,lock 檔是套件的一部分而非使用者輸入,仍然選擇只解析不執行,避免日後有人把 lock 檔當設定檔用而引入任意指令。

工作站端:pin-images.sh 與 check-pins.sh

scripts/pin-images.sh 在工作站或 CI 執行,不在 NAS 上。不帶參數時重新解析 lock 裡每一個 tag,帶 KEY=repository:tag 時只改一項。

scripts/pin-images.sh                                   # 重新解析所有 tag
scripts/pin-images.sh APP_IMAGE=traefik/whoami:v1.11.0  # 改一項

digest 的來源是 docker buildx imagetools inspect,只查 manifest,不 pull image。腳本拒絕沒有 tag 的參考,避免有人寫 traefik/whoami 然後鎖到當下的 latest,digest 沒錯但沒人知道那是哪一版。輸出格式刻意做成一行舊值一行新值,可以直接貼進 CHANGELOG。以下是實際把示範 App 換成前一版 v1.11.0 的輸出,截至今天 v1.12.0 就是 whoami 的最新版,所以這裡用降版示範。

APP_IMAGE traefik/whoami:v1.12.0@sha256:c4717a8d1f0134a7444e24f881160e033991f23027c6c5a9a3f8fd22e70d1d44
   -> traefik/whoami:v1.11.0@sha256:200689790a0a0ea48ca45992e0450bc26ccab5307375b41c84dfc4f2475937ab

scripts/check-pins.sh 是 CI 的閘門。它檢查兩件事,lock 裡每一行都符合 repository:tag@sha256:<64 hex> 的格式,以及服務腳本 CONTAINERS 列出的每一個容器加上 LANDING 都在 lock 裡有對應項目。少一個、格式錯一個,build 就不會開始。

NAS 端:digest 狀態、update 與 update --check

範本核心 shared/lib/qpkg-core.sh 在每次啟動後比對本機 image 的 RepoDigests 與鎖定值,v0.1.0 的每個容器得到四種狀態之一。

狀態 意義 處置
pinned-ok 本機 image 的 digest 與鎖定值相同
pinned-mismatch 有鎖定值,但本機 image 不符 事件紀錄寫入 Error 等級
unpinned 參考沒有 @sha256,通常是使用者在 .conf 覆寫成浮動 tag 事件紀錄寫入 Warning 等級
missing 尚未下載

狀態頁與 diag 都會顯示這個狀態。浮動 tag 仍然可以跑,範本沒有把它變成錯誤,理由是使用者有權自己決定版本,套件的責任是把「這個決定沒有被記錄」這件事講清楚。

這張表在隔離網段有一個缺口,v0.1.1 補上第五種狀態 unverifiableRepoDigests 只在 image 由 registry pull 下來時才有值,用 docker savedocker load 匯入的 image 是空的。實測的結果比「判成不符」更糟。以 tag 匯出再匯入,image 保有 tag,但 repository:tag@sha256:... 這個參考在本機完全查不到,docker image inspectdocker run 都回報 No such image,v0.1.0 會以為 image 還沒下載而進入下載流程,在沒有對外連線的網段最後停在 pull-failed。以 digest 匯出更麻煩,匯入後的 image 連名稱都沒有。v0.1.1 的修正是在鎖定參考查不到、而同名 tag 存在且完全沒有 repo digest 時改用 tag 啟動,並把狀態標為 unverifiable,事件紀錄寫入 Warning。只要那個 tag 帶有任何 repo digest,就代表它是從 registry 拉下來的另一個版本,不會拿來頂替鎖定值。隔離網段的建議仍然是架私有 registry,digest 驗證才能照常運作。

update 子命令的語意跟 Day 2 不同。鎖定 digest 之後,docker pull 同一個 digest 是冪等操作,update 變成「套用 lock 與設定檔目前指定的版本」,只有 image 或設定真的變了的容器才會重建。要真正升級有兩條路,在工作站執行 pin-images.sh 然後發新版套件,或者使用者在 NAS 上覆寫 .conf 再執行 update。前者有 git 紀錄,後者只有 NAS 上的檔案與 QTS 事件紀錄,兩條路都留著。

update --check 回答「上游動了沒」。它 pull 鎖定值的 tag 部分,比對拉下來的 digest 與鎖定的 digest,只印結果,不碰任何容器。示範 App 目前的輸出如下。

app: pinned traefik/whoami:v1.12.0, up to date (sha256:c4717a8d1f0134a7444e24f881160e033991f23027c6c5a9a3f8fd22e70d1d44)

若上游把同一個 tag 指到新內容,輸出會變成三行,第一行標示 upstream moved,後兩行分列鎖定值與上游目前的 digest。

app: pinned traefik/whoami:v1.12.0, upstream moved
    pinned  : sha256:<鎖定的 digest>
    upstream: sha256:<上游目前的 digest>

這條命令在 Day 6 的 changedetection.io 案例會被接進廠商公告監看,在 Day 26 談變更管理時會變成變更單的觸發條件。

二、SHA256SUMS 與建置來源證明

要驗證的是什麼

QPKG 未經 QNAP 簽章,個人開發者無法取得 App Center 的簽署金鑰。使用者在 App Center 允許安裝未簽署套件之後,防線只剩兩道。第一,下載的檔案等於 CI 產出的檔案。第二,CI 產出的檔案來自這個 repo 的這個 commit。

前提是 .qpkg 由 CI 建置,開發者不在本機建好再上傳。Jellyfin-QPKG 從 v1.2.1 開始把 committed 的 .qpkg 從 repo 移除,改由 GitHub Actions 在 tag 推上去時建置。範本的 build.yml 承接這個做法,並加上兩段。

      - name: Release files and checksums
        run: make release-files

      - name: Attest build provenance
        if: startsWith(github.ref, 'refs/tags/v')
        uses: actions/attest-build-provenance@v2
        with:
          subject-path: |
            build/*.qpkg
            build/images.lock

make release-filesimages.lockLICENSE 複製進 build/,然後對 .qpkg、images.lock、LICENSE 三個檔案一起算 SHA256SUMS,v0.1.1 起再加上 NOTICE.md。images.lock 附在 release 上的用意是讓使用者不用解開套件就能看到這一版鎖定了哪個 image。

第二道防線由 GitHub 的 artifact attestation 提供。GitHub 對 .qpkg 與 images.lock 簽署一份聲明,內容是「這個檔案由這個 repo 的這個 workflow 在這個 commit 上產出」,簽章憑證由 GitHub 的 OIDC 身分核發,開發者手上沒有可以外流的簽章金鑰,無法在 CI 之外替一個本機建好的檔案補簽。它證明的是出處,不是內容無害。帳號被盜的人仍然可以推一個惡意 commit 讓 CI 照常建置並簽署,所以 attestation 回答「這是不是從這個 repo 的哪個 commit 建出來的」,要不要相信那個 commit 仍然要看原始碼與 tag。workflow 需要 id-token: writeattestations: write 兩個權限,範本已宣告。

使用者端的驗證是兩行,第二行建議加上 --source-ref

sha256sum -c SHA256SUMS
gh attestation verify MyApp_0.1.0_x86_64.qpkg --repo ivanusto/qpkg-template --source-ref refs/tags/v0.1.0

--source-ref 的理由是 attestation 綁的是檔案內容,不是版本。v0.1.0 與 v0.1.1 的 images.lock 內容完全相同,這個檔案在 GitHub 上就有兩份 attestation,不加限制時任何一份都能讓驗證通過,實測挑中的是 v0.1.1 那一份。加上 --source-ref refs/tags/v0.1.0 之後只接受 v0.1.0 建置簽出的聲明,拿 v0.1.0 的 .qpkg 對 v0.1.1 驗證則回傳失敗。另外要留意,驗證成功時若輸出不是終端機,gh 可能不印任何文字,寫進腳本要看結束碼。

這兩道防線驗證不了的事

attestation 證明「這份 .qpkg 由 CI 從這個 commit 建出來」,證明不了「任何人從這個 commit 都能建出位元組相同的 .qpkg」。後者需要可重現建置,qbuild 做不到。實測在本機用同一份原始碼連續建置兩次,兩個 .qpkg 的 SHA-256 不同。原因是 qbuild 會把建置日期寫進 built_info、把建置時間寫進 built_version,打包時的 tar 與 gzip 也帶著當下的時間戳,而 qbuild 的選項裡沒有固定時間戳的設定。release 上的 .qpkg 與本機從同一個 tag 建出來的檔案因此也不會相同,只能靠 attestation 對照 CI 那一份。目前的做法是相信 GitHub 的 runner。

另一件兩道防線都管不到的事是 image 本身。SHA256SUMS 與 attestation 只涵蓋 .qpkg 與 images.lock,image 的完整性由 digest 負責,digest 的來源可信度由上游 registry 負責。三者合起來才涵蓋使用者實際拿到的所有東西。

三、授權檔

薄殼架構的授權問題有三層,每一層的回答不同。

套件本身的授權。 管理腳本與狀態頁是自己寫的,選什麼授權都可以。手上四個專案用了 Apache-2.0、MIT、GPL-3.0 三種,這是歷史因素。範本用 Apache-2.0,從範本建立的新專案跟著用,理由是它是寬鬆授權,又明確處理專利授權,適合讓別人拿去改成自己的套件。要注意 QNAP 官方的 qnap-dev/containerized-qpkgqnap-dev/QDK 兩個 repo 都沒有附授權檔。沒有授權檔在著作權上代表保留所有權利,所以範本對 containerized-qpkg 只在 NOTICE.md 註明參考其套件結構,QDK 則是在建置時由 Dockerfile 從官方 repo 取得,不隨範本散布。既有專案不改授權,避免對已下載的使用者造成混淆。

qpkg.cfgQPKG_LICENSE 欄位要與 LICENSE 檔一致。App Center 的套件資訊頁顯示的是這個欄位。範本的 qpkg.cfgApache-2.0,LICENSE 檔是 Apache-2.0,make release-files 把 LICENSE 附進 release,三處一致。roon-qpkg 目前不一致,會在下一版修正。

上游 image 的授權。 薄殼套件不重新散布 image,只在使用者的 NAS 上呼叫 Container Station 下載,所以上游授權不會約束套件本身。但使用者最終跑的是上游的軟體,README 應該告訴使用者他們接受了什麼。

上游 授權 備註
traefik/whoami Apache-2.0 範本示範用
Ollama MIT
Open WebUI Open WebUI License v0.6.5 以前為 BSD-3。v0.6.6(2025-04-19)起在 BSD-3 條款上加入品牌保護,不得移除或替換 Open WebUI 的名稱與 logo,30 天內使用者 50 人以下、取得書面同意或持有企業授權者除外。open-webui-ollama-qpkg 用 main tag,適用新條款;套件不更動品牌,不受影響
Jellyfin GPL-2.0 server 與 jellyfin-web 為 GPL-2.0。官方 image 內附的 jellyfin-ffmpeg--enable-gpl --enable-version3 建置,依 FFmpeg 的規則該部分為 GPL-3.0,所以整個 image 是兩種 GPL 版本的元件並存
Roon Server Roon Labs 專有授權 需要 Roon 訂閱,套件不含任何 Roon 軟體
ComfyUI GPL-3.0 自建 image 時會把 ComfyUI 原始碼打進 image,GPL 義務在使用者自己的 NAS 上,套件不散布 image

來源與商標。 Jellyfin-QPKG 的 NOTICE.md 處理了四件事。這是 fork 而且上游沒有授權檔,本專案的授權只涵蓋自己的修改。這是非官方套件,與上游專案和 QNAP 無隸屬關係。上游的名稱與 logo 是上游的商標。套件只自動化部署官方未修改的 image,不重新散布上游軟體。

範本 v0.1.0 還沒有這份檔案,今天補進 v0.1.1 的 NOTICE.md,並附進 release 與 SHA256SUMS。new-app.sh 建新專案時會把上游與商標兩段換成待填的佔位文字,避免新專案帶著 whoami 的說明出貨,測試也檢查這一點。格式如下。

# NOTICE

## 來源
本專案 <fork 自 X / 為原創>。<若為 fork 且上游無授權檔,說明本專案授權僅涵蓋自己的修改,並記錄聯繫上游的狀況>

## 關係聲明
本專案為非官方的社群維護套件,與 <上游專案> 及 QNAP Systems, Inc. 均無隸屬、維護或背書關係。

## 上游軟體
本套件僅自動化部署官方未經修改的 <image 名稱> image,不重新散布 <上游軟體>。<上游軟體> 依其自身授權(<授權名稱>,<連結>)提供,使用本套件即表示接受該授權。

## 商標
<上游名稱> 及其 logo 為 <上游組織> 的商標。QNAP、QTS、QuTS hero 與 Container Station 為 QNAP Systems, Inc. 的商標。

最佳化後,套件能回答的問題

問題 Day 2 Day 3
現在跑的 image 是哪一版 看 tag,內容不確定 images.lock 的 digest 唯一對應內容,狀態頁顯示是否相符
上游有沒有新版 不知道 update --check
上次升級改了什麼 沒有紀錄 pin-images.sh 的輸出進 CHANGELOG
下載的 .qpkg 是不是 CI 建的那一份 只有 MD5 SHA256SUMS 加 attestation,以 --source-ref 綁定版本
套件與上游各自的授權是什麼 部分專案沒有說明 LICENSE 隨 release 附上,NOTICE.md 說明上游
隔離網段匯入的 image 能不能驗證 不適用 能啟動,但標示 unverifiable,無法驗證
從原始碼能不能重建出同一個 .qpkg 不能 仍不能,qbuild 會寫入建置時間

最後一列留白。四個既有專案的遷移從 Day 5 的 Jellyfin-QPKG 與 roon-qpkg 開始,逐一改用範本骨架。

怎麼跑呢 ?

從範本開始。

git clone https://github.com/ivanusto/qpkg-template
cd qpkg-template
git checkout v0.1.0
make test           # shellcheck、check-pins、lifecycle 測試、new-app 測試
scripts/pin-images.sh                                   # 重新解析 lock 內每一個 tag
scripts/pin-images.sh APP_IMAGE=traefik/whoami:v1.11.0  # 改一項並看輸出
git diff shared/images.lock
git checkout shared/images.lock                         # 還原
make                # build/MyApp_0.1.0_x86_64.qpkg

make test 裡的 lifecycle 測試需要本機有 docker daemon,它用自己的容器名稱、網路與埠,先刪掉示範 image 再跑一次完整的背景下載路徑,不會碰到機器上其他東西。QTS 的 getcfgsetcfgwrite_logtests/stubs/ 下的替身取代。v0.1.1 的測試多了 docker load 匯入的情境。

驗證 release。

gh release download v0.1.0 --repo ivanusto/qpkg-template
sha256sum -c SHA256SUMS
gh attestation verify MyApp_0.1.0_x86_64.qpkg --repo ivanusto/qpkg-template --source-ref refs/tags/v0.1.0

裝到 NAS 之後看鎖定狀態。

sudo /etc/init.d/myapp.sh diag
sudo /etc/init.d/myapp.sh update --check

明天

Day 4 談 GitHub Actions 自動打包與 release。今天看到的 make release-files 與 attestation 是 CI 的其中兩步,明天把整條線攤開,從 make test 的四項檢查、QDK 在 runner 上的安裝、tag 推上去到 release 頁面出現 .qpkg 與回連文章的 release notes,以及 new-app.sh 怎麼從範本產出一個新專案。


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

本日程式碼:qpkg-template @ v0.1.0qpkg-template @ v0.1.1Jellyfin-QPKG @ v1.2.1

參考資料


上一篇
Day 2|Container Station 薄殼架構:QPKG 骨架、生命週期腳本與狀態頁
下一篇
Day 4|GitHub Actions 自動打包與 release:從 git tag 到可驗證的套件
系列文
地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言