Day 28:模型上線前該有哪些門
這是什麼、解決什麼問題
整個系列反覆出現的一句話:平台給你的是「可以擋」的能力, 不是「已經在擋」的機制。
具體來說:
- Model Registry 可以記錄版本,但不會阻止沒登記的模型上線
- image 掃描可以發現 CVE,但不會阻止有 CVE 的 image 部署
- 品質閘門可以比較分數,但門檻是你自己填的
- 資源上限可以設,但沒人驗過它會不會擋
這一篇講怎麼把「可以擋」變成「在擋」。
什麼時候你會用到
- 模型要從 PoC 進正式環境
- 資安或法遵問「怎麼防止未經核可的模型上線」
- 出過一次事,要補機制
五道門
一個模型從訓練完到上線,可以有五道門。 你不一定要五道都有,但你要知道自己有幾道。
| # | 門 | 擋什麼 | 在哪裡實作 |
|---|---|---|---|
| 1 | 資料門 | 資料品質、個資、來源授權 | pipeline 的第一個 step |
| 2 | 品質門 | 模型比上一版差 | pipeline 的評估 step |
| 3 | 供應鏈門 | image 有 CVE、來源不明 | build / registry |
| 4 | 登記門 | 沒登記的模型上線 | admission webhook |
| 5 | 上線門 | 人的核可 | 變更管理流程 |
多數團隊有 2 和 3,缺 1、4、5。
而 4 是唯一一道真的會在部署當下擋下來的技術性的門—— 其他的都在更早的階段,繞過它們只要不走那條路就行了。
門 2:品質門——⭐ 一個很容易犯的設計錯誤
最常見的寫法:
if new_score >= baseline_score - tol:
promote()
「不要比基準差太多」,看起來很合理。
但如果 baseline 每次都更新成上一次的分數,這就是一個滑坡。
假設 tol = 0.02:
| 次數 | 基準 | 這次 | 判定 |
|---|---|---|---|
| 1 | 3.00 | 3.02 | ✅ 過 |
| 2 | 3.02 | 3.04 | ✅ 過 |
| … | |||
| 20 | 3.38 | 3.40 | ✅ 過 |
二十次連續退步,每次都合格,累積劣化 0.40。 沒有任何一次觸發告警。
修法有三個,可以疊加:
- 絕對底線:
score <= 3.10,不管基準是多少 - 不可回退的基準(ratchet):基準只能變好,不能跟著變差
- 累積偵測:連續 N 次接近門檻就告警
光有第 1 種就能擋住上面那個滑坡。
門 4:登記門——唯一真的會擋的那一道
想法很簡單:部署 InferenceService 的時候,
檢查它的模型版本有沒有在 registry 裡。沒有就拒絕。
實作是一個 validating admission webhook。我真的做了一個,核心只有幾行:
claimed = ann.get("model/sha256") or lbl.get("model-registry/version")
if not claimed:
deny(f"InferenceService '{name}' 沒有標註模型版本。")
elif claimed not in registry_versions():
deny(f"模型版本 '{claimed}' 不在台帳上(台帳有 {n} 筆)。")
⭐ namespaceSelector 是讓它可以先在一個 namespace 試跑的關鍵:
namespaceSelector:
matchLabels: {model-gate: enforced}
只有貼了那個 label 的 namespace 會被管。 這讓你能先在 dev 開、 確認訊息夠清楚、再往 prod 推——不用一次賭整個叢集。
⭐ 五個反向測試,我全跑過
| 測試 | 結果 |
|---|---|
| 沒標版本就部署 | ✅ 擋下,訊息直接說要加哪個 annotation |
| 標一個假版本 | ✅ 擋下:「不在台帳上(台帳有 3 筆)」 |
| 標台帳上真的有的版本 | ✅ 放行 |
| webhook 讀不到台帳 | ✅ 擋下——見下 |
| 沒開登記門的 namespace | ✅ 不受影響 |
第四項是意外驗到的。我第一版有個 bug(去讀了自己的 namespace, 台帳其實在另一個),結果是:
denied: 登記門讀不到台帳,保守拒絕:HTTP Error 403: Forbidden
failurePolicy: Fail 正確地把「我不知道」當成「不可以」。
這一條值得停一下:護欄壞掉的時候,它的預設行為是什麼? 如果我當時設的是
Ignore,那個 bug 會讓登記門靜靜地放行所有東西, 而且沒有任何人會發現——因為部署都成功了。一道會在自己壞掉時放行的門,比沒有門更危險:你以為有。
⚠️ 代價要講清楚:Fail 表示 webhook 掛掉時沒有人能部署模型。
所以它自己要有監控、要有多副本。這是你要跟營運換的取捨,不是免費的。
⚠️ failurePolicy 要想清楚:
| 設定 | webhook 掛掉時 | 適合 |
|---|---|---|
Fail |
所有部署都被擋 | 安全優先 |
Ignore |
全部放行 | 可用性優先 |
注意 failurePolicy 只處理「webhook 連不上」,
不處理「webhook 說不行」——後者一定會擋。
這也表示:Ignore 加上一個掛掉的 webhook,等於沒有門。
所以 webhook 本身要有監控。
⭐ 一個我 lab 上真的發生的缺口
我做了品質閘門,它會判斷候選模型能不能上。 某次它判定通過。
然後我去看線上服務——跑的還是舊的那個。
- 候選模型:5.5 MB,8/28 產出
- 線上服務:32 MB,8/24 部署
因為沒有任何東西實作「通過之後把它換上去」這一步。
閘門是綠的、報告是對的、模型是好的,只是它沒上線。
這是最典型的「可以擋 vs 在擋」: 我做了判斷的部分,沒做執行的部分。 而所有的檢查點都顯示正常,因為它們檢查的都是判斷那一段。
驗證方式很簡單,而且該進驗收清單:
# 閘門通過的那個 digest
cat gate-result.json | jq -r '.candidate_sha256'
# 線上實際跑的
oc exec <pod> -c kserve-container -- sha256sum /mnt/models/model.bin
兩個要相等。
⭐ 所以「換上去」那一步要自己寫——長這樣
上面那個缺口我後來補起來了,而且在 lab 上跑通。這裡是最小可用的版本。
在 pipeline 的 gate 之後多接一棒,做三件事:
# 1. 讀台帳,把這一輪的模型標成 production
reg = load_registry_from_s3()
newest = reg[-1] # gate 剛寫進去的那筆
for e in reg:
if e["status"] == "production":
e["status"] = "archived" # 舊的降級
newest["status"] = "production"
newest["lineage"]["promoted_by"] = "pipeline"
save_registry_to_s3(reg)
# 2. 推到服務讀得到的路徑(每一版一個路徑,不要覆蓋)
for f in ("ckpt.pt", "tokenizer.json", "clean_corpus.txt"):
put(f"/work/artifacts/{f}", "models", f"llm-v{n}/{f}")
# 3. 把台帳同步回叢集,服務端才看得到
patch_configmap("model-registry", {"registry.json": json.dumps(reg)})
第 3 步在容器裡不需要 oc,用內建的 SA token 打 API 就好:
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
tok = open(SA + "/token").read()
ns = open(SA + "/namespace").read()
req = urllib.request.Request(
f"https://kubernetes.default.svc/api/v1/namespaces/{ns}/configmaps/model-registry",
data=body, method="PATCH",
headers={"Authorization": "Bearer " + tok,
"Content-Type": "application/strategic-merge-patch+json"})
三個我實際撞到的坑
① 要給 pipeline 的 SA 權限,而且要給最小的。
預設沒有改 ConfigMap 的權限。用 resourceNames 鎖死成那一個:
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["model-registry"] # ← 只有這一個
verbs: ["get", "patch", "update"]
② ⚠️ 不要覆蓋共用的台帳——我就是這樣弄丟一個模型的。
我的台帳有兩份來源,pipeline 讀的那份原本不存在, 所以 gate 從一個空台帳開始寫、promote 用只有一筆的台帳蓋掉了共用設定。
結果:原本在跑的另一個服務,digest 沒變、pod 沒重啟,
但它的 /model 從 production 變成 UNREGISTERED。
登記新模型成功的同一個動作,把舊模型從名冊上抹掉了。 台帳要合併不要覆蓋,而且先確認只有一份來源。
③ 每一版一個路徑。 覆蓋同一個路徑的話,git 和台帳都看不出模型換過(Day 18)。
怎麼知道真的接上了
問服務自己:
curl -s http://<svc>/model | jq
| 手動 promote | pipeline promote | |
|---|---|---|
serving_digest |
對 | 對 |
in_registry |
false | true |
status |
UNREGISTERED | registered / production |
metrics |
null | 有評估數字 |
digest 對不代表接上了。 這張表右邊那三列才是「這顆模型有身分」的意思—— 而它們全都來自 promote 那一步。
怎麼確認你的門真的在擋
每一道門都要有一次反向測試。
| 門 | 反向測試 |
|---|---|
| 資料門 | 餵一份有問題的資料,確認 pipeline 停下來 |
| 品質門 | 餵一個明顯更差的模型,確認被拒絕 |
| 供應鏈門 | 用一個已知有 CRITICAL CVE 的 image,確認被擋 |
| 登記門 | 部署一個沒 label 的 ISvc,確認被拒絕 |
| 上線門 | 跳過核可直接部署,確認做不到 |
每一項都要留下「被擋下來」的證據截圖。 這是 Day 26 說的護欄層條目,也是稽核最想看的東西。
⭐ 加上一條閘門到上線的對帳:
閘門通過的那個 digest,等於線上正在跑的 digest。
這一條會抓到我上面那個缺口,而前五條都不會。
常見問題
Q:五道門都要有嗎? A:不用,但你要講得出自己有幾道、缺哪幾道、為什麼。 「我們評估後決定不做登記門,因為只有兩個模型且都由同一人管理」 是可以接受的答案;「我們有做治理」不是。
Q:webhook 會不會拖慢部署?
A:會,但很少(毫秒級)。
真正的風險是 webhook 掛掉時的行為,那要靠 failurePolicy 和監控。
Q:admission webhook 要自己寫嗎? A:可以用 Kyverno 或 OPA Gatekeeper 寫規則,不用自己寫程式。 在 OpenShift 上這兩個都常見。
Q:這些跟 AI 法規有什麼關係? A:法規會問的是「你怎麼確保只有經過核可的模型在服務」。 這五道門就是那題的技術答案,而 Day 30 會講證據那一面。
你們這條鏈上,哪一個點真的會擋下東西?
🧪 這篇的實驗環境與 lab 檔案(最後更新 2026-08-30)
叢集
- CRC 2.63.0 · OpenShift 4.22.7 · Kubernetes v1.35.6
- 單節點:13 vCPU / 40 GiB RAM / 120 GB disk
- 宿主:Framework Laptop 16(Ryzen AI 7 350 · 8C/16T · 64 GB RAM · 1 TB NVMe · RTX 5070 顯卡模組)
- ⚠️ 叢集內看不到 GPU(CRC 是 VM,RTX 5070 未 passthrough)
Operator
opendatahub-operator.v3.5.0← 即 RHOAI 3.x 的上游開源版cert-manager-operator.v1.20.0(3.x 的必要相依;2.x 不需要)openshift-pipelines-operator-rh.v1.23.2、openshift-gitops-operator.v1.21.3
DataScienceCluster 開啟的元件
kserve、aipipelines、dashboard、workbenches、modelregistry、kueue(Unmanaged)- 其餘(
ray/trustyai/feast/aigateway…)為Removed
叢集外的依賴(跑在宿主的 podman 上,crc start 不會帶起來)
- Harbor v2.15.2(私有 registry)· MinIO(S3)
模型端
- Python 3.12.13 · PyTorch 2.11.0+cu128 · FastAPI + uvicorn · prometheus-client
lab 檔案
- YAML/Containerfile/腳本:github.com/ryanGTR/openshift-ai-30days
(含 Day 對照表;主機名是佔位符,跑
set-lab-host.sh換成你自己的) - 服務的那個模型:llm-from-scratch(從零手刻的小 GPT)
⚠️ ODH ≠ RHOAI:元件同源,但 namespace 與部分名稱不同
(我這裡是 opendatahub,商用版是 redhat-ods-* 那一套)。
指令的邏輯可以照用,字串要自己對一次。
留言與指正
我特別想知道:你們這條鏈上,哪一個點真的會擋下東西?