這是什麼、解決什麼問題

你照著 Day 4 裝好了平台,然後在公司環境重做一次——卡在第一步。

ImagePullBackOff。因為 quay.io 連不到。

這不是例外,這是金融業的常態。 而且影響的不只是安裝: 每個 workbench image、每個 ServingRuntime、 pipeline 裡每個 packages_to_install——全部都要有離線的來源。


什麼時候你會用到

  • 叢集在內網、連不到外部 registry
  • 合規要求「所有 image 必須經過內部掃描」
  • 用內部 Harbor / Quay / JFrog

前置條件

  • 一個內部 registry
  • 一台同時連得到外網和內部 registry 的擺渡機
  • skopeo(或 oc mirror

步驟一:先搞清楚有三件事要做

這是最多人卡住的地方——他們做了第一件,以為就完成了。

  做什麼 沒做會怎樣
A 搬 image 複製到內部 registry image 不存在
B 改路由 設 mirror 規則 image 在,但叢集還是去 quay.io 找
C 給權限 pull secret + TLS 信任 找得到,但拉不動

三件事是獨立的,而且錯誤訊息不一樣——步驟五那張表會用到。


步驟二:搬 image(A)

skopeo copy --all \
  docker://quay.io/opendatahub/odh-workbench-jupyter-datascience-cpu-py312-ubi9@sha256:<digest> \
  docker://registry.internal:8088/odh/odh-workbench-jupyter-datascience-cpu-py312-ubi9:3.5

⚠️ 來源用 digest,目的地用 tag。

因為 mirror 規則是按 digest 比對的(下一步會看到), 你搬的必須是規則會去找的那一顆,而 tag 隨時可能指向不同的 image

驗證這一步:

skopeo inspect docker://registry.internal:8088/odh/<name>:3.5 | jq -r '.Digest'

digest 要跟來源一模一樣。

我 lab 上搬一個 workbench image 花了 17 分 26 秒、1,698 MB。 完整的平台有幾十個——這是排期時要算進去的


步驟三:告訴叢集去哪找(B)

OpenShift 4.x 用 ImageDigestMirrorSet

apiVersion: config.openshift.io/v1
kind: ImageDigestMirrorSet
metadata:
  name: odh-workbench-mirror
spec:
  imageDigestMirrors:
  - source: quay.io/opendatahub/odh-workbench-jupyter-datascience-cpu-py312-ubi9
    mirrors:
    - registry.internal:8088/odh/odh-workbench-jupyter-datascience-cpu-py312-ubi9

⚠️ IDMS 只對 digest 形式的參照生效。 YAML 裡寫 image: quay.io/xxx:3.5(tag 形式)不會被改寫—— 這是名字裡 Digest 的意思。(tag 需求要用 ImageTagMirrorSet。)

這不是推論,套用之後節點上就是這樣寫的

oc debug node/<node> -q -- chroot /host cat /etc/containers/registries.conf
[[registry]]
  location = "quay.io/opendatahub/odh-workbench-jupyter-datascience-cpu-py312-ubi9"
  blocked = true
  [[registry.mirror]]
    location = "registry.internal:8088/odh/odh-workbench-jupyter-datascience-cpu-py312-ubi9"
    insecure = true
    pull-from-mirror = "digest-only"      # ← 就是這一行

上面那個 blocked = true 來自一個你該自己決定的欄位

  - source: quay.io/opendatahub/odh-workbench-jupyter-datascience-cpu-py312-ubi9
    mirrors:
    - registry.internal:8088/odh/odh-workbench-jupyter-datascience-cpu-py312-ubi9
    mirrorSourcePolicy: NeverContactSource     # ← 加這行才會 blocked
mirrorSourcePolicy 行為
AllowContactingSource預設 mirror 拉不到就回去試原來的來源
NeverContactSource 節點上 blocked = true絕不出門

離線環境應該選 NeverContactSource——不然你會以為自己離線了, 其實每次 mirror 沒命中它都在偷偷敲外網(在真的斷網的機房就是變成逾時)。 這個選擇會直接改變你在步驟五看到的錯誤訊息。

套用後節點會逐台重啟 CRI-O

oc get imagedigestmirrorset
oc get mcp        # 等 UPDATED=True

步驟四:憑證與權限(C)

TLS 信任

如果你的 registry 是 HTTPS 且用自簽憑證,要讓叢集信任它:

oc create cm registry-certs -n openshift-config \
  --from-file=registry.internal..8088=ca.crt

oc patch image.config.openshift.io/cluster --type=merge \
  -p '{"spec":{"additionalTrustedCA":{"name":"registry-certs"}}}'

⚠️ 檔名格式很特別:主機名裡的冒號寫成兩個點(registry.internal..8088)。

📌 誠實標註:這一段是照 OpenShift 文件寫的,我 lab 上沒有走過這條路—— 我那台 Harbor 是 HTTP,走的是下面那條捷徑。所以「冒號寫兩個點」我沒有實際踩過。

我 lab 實際用的(正式環境不要),它關掉的是 TLS 驗證, 中間人可以換掉你的 image

oc patch image.config.openshift.io/cluster --type=merge \
  -p '{"spec":{"registrySources":{"insecureRegistries":["registry.internal:8088"]}}}'

套用後節點上會長出這個:

[[registry]]
  prefix = ""
  location = "registry.internal:8088"
  insecure = true

pull secret——三層,選對一層

影響範圍 什麼時候用
叢集全域(openshift-config/pull-secret 所有 namespace 平台自己要拉的
ServiceAccount 那個 SA 起的所有 pod 最常用
Pod(imagePullSecrets 單一 pod 特例

⚠️ 最常見的錯誤:建了 Secret,以為就生效了。 Secret 必須被 SA 連結、或被 pod 明確引用。

oc create secret docker-registry harbor-pull \
  --docker-server=registry.internal:8088 \
  --docker-username='robot$odh+puller' \
  --docker-password='<token>' -n <ns>

oc secrets link default harbor-pull --for=pull -n <ns>

⚠️ Harbor 的 robot 帳號含 $+,shell 裡一定要單引號, 不然會被展開成空的——而錯誤訊息只會說認證失敗

而且 OpenShift AI 的 pod 不都用 default SA:

oc get pods -n <ns> -o custom-columns='SA:.spec.serviceAccountName' --no-headers | sort -u

我那個裝了 pipeline、workbench 和模型服務的 namespace,跑出來是這樣:

default
ds-pipeline-metadata-envoy-dspa
ds-pipeline-metadata-grpc-dspa
ds-pipeline-workflow-controller-dspa
ds-pipelines-mariadb-sa-dspa
llm-sa

一個 namespace 裡六種 SA。 只連 default 的話, 上面那五個起的 pod 全部拉不到 image——而且它們是平台自己建的,你沒辦法預先知道有幾個。

每一個用到的 SA 都要連。


步驟五:⭐ 真的拉一次,然後看錯誤訊息分類

oc run pulltest --rm -it --restart=Never \
  --image=registry.internal:8088/odh/some-image:3.5 -- echo ok

失敗的話,訊息會告訴你是三件事裡的哪一件

⚠️ 一定要加 --image-pull-policy=Always 節點上如果已經有那顆 image 的快取, kubelet 根本不會去拉,你會看到一個毫無意義的「成功」——我第一次測就這樣過關了兩次。

訊息 是哪一件 回去看
unknown: repository <名字> not found
(或 manifest unknown,看 registry 實作)
A:image 不在那裡 步驟二
registry <來源> is blocked in /etc/containers/registries.conf B:你用了 tag。mirror 是 digest-only,而來源被 NeverContactSource 擋掉了 步驟三,改用 digest
還是去外網拉(或離線時逾時) B:同上,但 mirrorSourcePolicy 沒設 NeverContactSource,所以它還會去試來源 步驟三
x509: certificate signed by unknown authority C:不信任 registry 步驟四 TLS
unauthorized: authentication required C:沒憑證或沒連上 SA 步驟四 pull secret
no route to host 網路/防火牆 不是這篇的問題

這張表能省下很多亂試的時間——每個症狀對應完全不同的原因。

第二列是我實測才發現要拆開寫的。 我原本以為「用了 tag」的症狀是 「它還在連外網」,實際上我的 IDMS 設了 mirrorSourcePolicy: NeverContactSource, 節點上對應的是 blocked = true

Failed to pull image "quay.io/prometheus/prometheus:v3.6.0":
  registry quay.io/prometheus/prometheus is blocked in /etc/containers/registries.conf

它連試都不會試。 對離線環境來說這是好事(保證不出門), 但症狀跟「mirror 沒生效」長得完全不一樣—— 看到 blocked 不要去查網路,去看你是不是用了 tag。

同一顆 image 改用 digest:

Successfully pulled image "quay.io/prometheus/prometheus@sha256:76947e7e…"
  in 91ms. Image size: 314435533 bytes

91 毫秒、300 MB——從區網那台 registry 拉的,沒出過門。


步驟六:別忘了 image 以外的東西

這是離線環境最容易漏的一節。

東西 離線會怎樣 怎麼辦
pipeline 的 packages_to_install ❌ pip 連不到 PyPI 自己 build base image
workbench 裡 pip install ❌ 同上 內部 PyPI mirror
HuggingFace 模型下載 ❌ 連不到 先下載,放 S3
operator 更新 ❌ OperatorHub 連不到 離線 catalog

最後一列常被忽略,然後半年後要修 CVE 時才發現升不了級。 評估離線可行性時,把「怎麼更新」一起問。


怎麼確認做對了

  檢查 怎麼看
1 image 在、digest 一致 skopeo inspect
2 IDMS 存在、節點更新完 oc get idmsoc get mcp
3 Secret 連上了 SA oc get sa <sa> -o jsonpath='{.imagePullSecrets}'
4 真的拉得下來 步驟五那個 pod
5 權限是最小的 用 pull 帳號試推,應該要 401
6 斷網再試一次 見下

第 6 項是唯一真的驗證:

把叢集對外的連線切掉,然後刪掉一個 pod 讓它重拉。

起得來,才代表你真的離線可用。 沒做這件事,你不知道有多少東西是靠著「其實還連得到」在跑。


常見問題

Q:oc mirrorskopeo 選哪個? A:oc mirror 能整批處理 operator catalog,適合初次建置skopeo 精準,適合補漏和日常維護。兩個都會用到。

Q:要 mirror 多少個 image? A:沒有人能事先列全。 先在能連外網的環境裝一次,然後:

oc get pods -A -o json | jq -r '.items[].spec.containers[].image' | sort -u

這份清單就是你的搬運清單。

Q:pull 和 push 可以用同一個帳號嗎? A:不要。給推送權限的憑證如果被用在每個拉取的 pod 上, 任何能進那個 pod 的人都能覆蓋你的 image。

Q:robot 帳號會過期嗎? A:Harbor 的 robot 可以設有效期,預設有。 症狀是「昨天還好好的,今天全部拉不到」——把到期日記進行事曆

Q:這些設定能寫進 GitOps 嗎? A:IDMS 和 image config 是 cluster-scoped 的 CR,可以,而且建議一定要—— 這是重建叢集時最容易漏的一塊。Day 25 會談。


你們的叢集連得到外網嗎?如果不能,image 現在怎麼進來的?

🧪 這篇的實驗環境與 lab 檔案(最後更新 2026-08-30)

本篇在 ODH 3.6.0-ea.1 上實跑對帳(共用區塊寫的 3.5.0 是 Day 1–9 的版本)。lab 的 Harbor 走 HTTP,所以下面 TLS 那節是照文件寫的、未在本 lab 驗證,實際用的是 insecureRegistries

叢集

  • 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.2openshift-gitops-operator.v1.21.3

DataScienceCluster 開啟的元件

  • kserveaipipelinesdashboardworkbenchesmodelregistrykueue(Unmanaged)
  • 其餘(raytrustyaifeastaigateway…)為 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 檔案

⚠️ ODH ≠ RHOAI:元件同源,但 namespace 與部分名稱不同 (我這裡是 opendatahub,商用版是 redhat-ods-* 那一套)。 指令的邏輯可以照用,字串要自己對一次。